diff --git a/.gitea/ISSUE_TEMPLATE/bug_report.md b/.gitea/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000..cf21181 --- /dev/null +++ b/.gitea/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,45 @@ +--- +name: Something here is wrong +about: A chapter is inaccurate, an example does not work, or the template does not build +title: "[bug] " +labels: + - bug +--- + +## What is wrong + + + +## What happened + + + +## What you expected + + + +## Where you were + +- File / chapter: +- Kit commit: +- Core version you built against (`MODULE_API_VERSION`), if known: +- Node version: + +## Additional context + + + + diff --git a/.gitea/ISSUE_TEMPLATE/config.yaml b/.gitea/ISSUE_TEMPLATE/config.yaml new file mode 100644 index 0000000..11675dd --- /dev/null +++ b/.gitea/ISSUE_TEMPLATE/config.yaml @@ -0,0 +1,8 @@ +blank_issues_enabled: true +contact_links: + - name: Security vulnerability + url: https://gitea.whitlocktech.com/RunicGateway/Integration-kit/src/branch/main/SECURITY.md + about: Please do not open a public issue for security problems — report them privately by email instead (see SECURITY.md). + - name: The module contract itself + url: https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/website/MODULE_API.md + about: This repo teaches the contract but never defines it. If a rule looks wrong rather than badly explained, it belongs against MODULE_API.md in the docs repo. diff --git a/.gitea/ISSUE_TEMPLATE/feature_request.md b/.gitea/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 0000000..20eb2c5 --- /dev/null +++ b/.gitea/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,39 @@ +--- +name: Something is missing +about: A question the kit left you unable to answer +title: "[gap] " +labels: + - enhancement +--- + +## What you were trying to do + + + +## Where you got stuck + + + +## What you did in the end + + + +## Is it a gap in the kit, or in the contract? + +- [ ] The contract can already do this; the kit does not explain how. +- [ ] The contract cannot do this at all. +- [ ] Not sure. + + diff --git a/.gitea/PULL_REQUEST_TEMPLATE.md b/.gitea/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..977b984 --- /dev/null +++ b/.gitea/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,33 @@ + + +## What & why + + + +## How it was tested + + + +## Checklist + +- [ ] I have read [CONTRIBUTING.md](CONTRIBUTING.md). +- [ ] The change builds and existing tests/checks pass locally. +- [ ] I have added or updated tests/docs where it makes sense. +- [ ] My commits are reasonably scoped with clear messages. + +## AI-assisted contributions (required) + +This project **requires disclosure of AI tool usage**. Please pick one: + +- [ ] No AI tools were used to produce this contribution. +- [ ] AI tools were used. Tool(s): `___________`. I have reviewed and understand + every change, and take responsibility for it. AI-authored commits are + marked with a `Co-Authored-By` / `Assisted-By` trailer. + +## License + +- [ ] I agree that my contribution is licensed under this project's license + (**GNU GPL v3.0 or later**), and I have the right to contribute it. diff --git a/.gitea/workflows/pr-checks.yml b/.gitea/workflows/pr-checks.yml new file mode 100644 index 0000000..ef23d30 --- /dev/null +++ b/.gitea/workflows/pr-checks.yml @@ -0,0 +1,138 @@ +# Gate every pull request into `main`. This repo is documentation plus a template +# module, so the checks are about whether the documentation is still TRUE rather +# than whether software works. +# +# ── What each job is really asking ─────────────────────────────────────────── +# +# • `links` — every relative link resolves, and no link pins a reader to a +# commit snapshot of a document that moves. Nothing is fetched: this project's +# Gitea is self-hosted, so an HTTP check would fail on a runner without +# credentials and teach everyone to ignore red. What breaks in practice is a +# relative path after a file moves, and that is answerable offline. +# +# • `template` — the interesting one, and the anti-rot mechanism of the whole +# repo (MODULE_SYSTEM.md §2.11.1 d2). It clones CORE at the ref pinned in +# `ci/core-ref.json` and asks three things: +# +# 1. does `template/module.json`'s `coreApi` still EQUAL that core's +# `MODULE_API_VERSION`? Equality, not "satisfies" — a range check would +# stay green across a contract bump, and green would then mean "the +# template still loads" when we need it to mean "someone has re-read the +# book since the contract changed". This failing is the system working. +# 2. does the template still build? A kit whose examples do not compile is +# worse than no kit, because the reader trusts it first. +# 3. do the template's own boundary guards still pass? They are the same +# checks a real module ships (MODULE_API.md §5.1, §3.6), and the template +# is what teaches a newcomer that they exist. +# +# ── The guard, and why the template job can report green with no template ──── +# +# Slice 0 is this scaffold; the template lands in slice 1. Rather than leave the +# repo ungated in between, or land a workflow that red-Xes every docs PR until +# there is something to build, the template steps are conditional on +# `template/module.json` existing. Before it lands the job prints why it did +# nothing; the moment the file appears the job arms itself with no edit here. +# Same guard Module-uo#1 used through its own planning phase. +# +# Enforcement (one-time, in the Gitea UI): +# Repository Settings → Branches → Branch Protection (rule for `main`) +# • Enable Status Check +# • Status check patterns: PR Checks / * +# Gitea only lists a context in its dropdown after it has reported once, so let +# this run on one PR first. The glob keeps matching as jobs are added. +# +# Runner: the shared self-hosted `ubuntu-latest` runner. Node only — no database, +# no Docker socket. + +name: PR Checks + +on: + pull_request: + branches: [main] + +concurrency: + group: pr-checks-${{ github.ref }} + cancel-in-progress: true + +env: + NPM_CONFIG_FETCH_RETRIES: 5 + NPM_CONFIG_FETCH_RETRY_MINTIMEOUT: 20000 + NPM_CONFIG_FETCH_RETRY_MAXTIMEOUT: 120000 + +jobs: + links: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 20 + + # No dependencies on purpose — this has to run on a clone with nothing + # installed, which is also how a reader will run it. + - name: Check every link in the book + run: node scripts/checkLinks.js + + template: + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 20 + + - name: Is there a template yet? + id: guard + run: | + if [ -f template/module.json ]; then + echo "present=true" >> "$GITHUB_OUTPUT" + else + echo "present=false" >> "$GITHUB_OUTPUT" + echo "No template/module.json — the template lands in Phase 5 slice 1." + echo "The steps below are skipped until it does; see this file's header." + fi + + # Anonymous HTTPS, and a full clone rather than a shallow one: the pin is a + # commit sha, and `--depth 1` can only fetch a branch tip. + - name: Clone core at the pinned ref + if: steps.guard.outputs.present == 'true' + run: | + REPO=$(node -p "require('./ci/core-ref.json').repo") + REF=$(node -p "require('./ci/core-ref.json').ref") + echo "core: $REPO @ $REF" + git clone --quiet "$REPO" .core + git -C .core checkout --quiet "$REF" + + - name: Is the kit still written against this core? (MODULE_SYSTEM.md §2.11.1 d2) + if: steps.guard.outputs.present == 'true' + run: node scripts/checkCoreApi.js --core .core + + - name: Install the template's deps + if: steps.guard.outputs.present == 'true' + run: | + npm ci --prefix template/server + npm ci --prefix template/client + + - name: Check the template's module boundary (MODULE_API.md §5.1) + if: steps.guard.outputs.present == 'true' + run: npm run check:imports --prefix template/server + + # The build comes before the externals check because that check reads the + # BUILT chunk: whether `import { useState } from 'react'` became core's React + # or a bare specifier no browser can resolve is decided by vite.config.js, and + # is invisible in source. + - name: Build the template's client chunk + if: steps.guard.outputs.present == 'true' + run: npm run build --prefix template/client + + - name: Check the built chunk's externals (MODULE_API.md §3.6) + if: steps.guard.outputs.present == 'true' + run: npm run check:externals --prefix template/client + + - name: Run the template's tests + if: steps.guard.outputs.present == 'true' + run: npm test --prefix template/server diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..39d8997 --- /dev/null +++ b/.gitignore @@ -0,0 +1,42 @@ +# dependencies +node_modules/ +template/server/node_modules/ +template/client/node_modules/ + +# The template's built client chunk. A module ships this PREBUILT — an operator +# never builds anything — but it is a release artifact, not a source one, and a +# reader who clones this repo should build it themselves once. Committing it would +# also mean a stale chunk sitting next to fresh template source, which is exactly +# the failure mode the chapter warns about. +template/client/dist/ + +# A checkout of core, if you clone one in here to develop against. CI puts it in +# .core; do not commit either. +.core/ +core/ + +# test coverage +coverage/ + +# env / secrets +.env +*.env +!.env.example + +# release staging +/dist/ +*.tar.gz + +# logs / os +*.log +npm-debug.log* +.DS_Store +Thumbs.db + +# editor / tooling local settings +.claude/settings.local.json +.vscode/ +.idea/ + +# local planning docs (not part of the tracked repo) +.plans/ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..53c6877 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,135 @@ +# Contributing to Runic Gateway — Integration Kit + +Thanks for your interest! This repo is the **Integration Kit**: the instruction +book for putting a game that is not Ultima Online onto a Runic Gateway site, plus +a `template/` module that builds. + +It is the only repo in this project written for someone **outside** the +organisation, and that changes what a contribution looks like here. Elsewhere the +docs record decisions; here they teach a stranger. The most valuable contribution +is not a correction — it is a report of the place you got stuck and what you did +next. + +By participating you agree to abide by our [Code of Conduct](CODE_OF_CONDUCT.md). + +## Status: draft + +The kit's acceptance test is that **someone outside this project builds a working +module for a new game by following it alone, without reading core's source.** Until +that has happened it is a draft however finished it looks, and the README says so +on the front page. Do not remove that banner in a PR that is not the one recording +a passed acceptance run. + +## The rule that governs every change here + +**The kit never re-specifies a contract.** These are normative; the kit teaches and +links out: + +- [`MODULE_API.md`](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/website/MODULE_API.md) + — everything a module may do. +- [`MODULE_SYSTEM.md`](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/website/MODULE_SYSTEM.md) + — why the system is shaped this way, and the install model. +- [`link/PLAN.md`](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/PLAN.md) + and [`INTEGRATION.md`](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/INTEGRATION.md) + — the game↔sidecar wire protocol. + +So: **do not copy a member list, a field table or a set of rules into a chapter.** +Quote the part you are explaining, with a link, and let the reader go to the +authority for the whole. A guide that restates a contract diverges from it +silently, and the reader who follows the divergent copy gets a module that fails +validation for reasons the guide cannot explain. + +If a rule looks *wrong* rather than badly explained, that is a docs-repo PR against +`MODULE_API.md`, not a change here. The kit does not get to disagree with the +contract in prose. + +## Ways to contribute + +- **Report where the kit failed you** through the + [issue tracker](https://gitea.whitlocktech.com/RunicGateway/Integration-kit/issues) + — templates provided. "I could not work out how to X" is a bug in this repo. +- **Fix or extend a chapter**, or the template, with a pull request. +- **Never** report a security vulnerability in a public issue — see + [SECURITY.md](SECURITY.md). + +## Working on this repo + +**Prerequisites:** Node 20+, and a checkout of +[`RunicGateway/website`](https://gitea.whitlocktech.com/RunicGateway/website) if you +are touching `template/` — a module is not runnable on its own. + +```bash +node scripts/checkLinks.js # every relative link resolves; no commit permalinks +``` + +The template's own checks live under `template/` and are described in the chapter +that introduces it. + +### What CI checks, and why it is shaped that way + +`.gitea/workflows/pr-checks.yml` clones core at a **pinned commit** and then: + +1. asserts the version `template/module.json` declares still **equals** that core's + `MODULE_API_VERSION`; +2. builds the template and runs its boundary guards; +3. runs the link check over every markdown file. + +Check 1 is the anti-rot mechanism. A contract bump in core changes that number, this +repo goes red, and someone has to look at whether a chapter has become untrue — which +is the half a machine cannot check. That is also why a `MODULE_API_VERSION` bump in +core carries an obligation to pass over this kit in the same PR +([`MODULE_SYSTEM.md` §2.10](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/website/MODULE_SYSTEM.md)). + +**The pin points at a commit on core's `edge` branch, not `main`.** The module system +has not cut over yet; `main` has no `server/src/modules/` at all. Moving the pin is a +deliberate, reviewed change — it is the moment someone re-reads the chapters — so it +is a line in the workflow rather than a floating branch name. + +### Prose conventions + +- **Second person, present tense.** "You register a route", not "the module author + registers a route". +- **Say why before how.** Every rule in this project exists because something broke; + a rule without its reason is one the reader will route around. +- **Examples are quotations.** Code blocks come from `template/` or from a real repo, + with a path next to them. An invented example is one nobody has compiled. +- **Own the mistakes.** Where this project got something wrong and paid for it, the + kit says so plainly. That is the part a reader cannot get anywhere else. +- One sentence per line is not required, but keep lines under ~100 characters. + +### Commit messages + +[Conventional Commits](https://www.conventionalcommits.org/) — `type(scope): summary`. +`docs:` for chapters, `feat:`/`fix:` for the template, `ci:` for workflows. + +## Branch & PR workflow + +1. Branch from `main` (`feature/…`, `fix/…`, `docs/…`, `chore/…`). +2. Keep it focused. +3. Open a pull request against `main` and fill in the template, including the + **AI-assisted contributions** disclosure. +4. A maintainer reviews; address feedback with follow-up commits. + +This repo develops on its own `main` from its first commit — no `edge`, no cutover. + +## AI-assisted contributions (disclosure required) + +This project is developed openly with AI assistance, and we ask the same +transparency of everyone. **If you used an AI tool** (Claude, Copilot, ChatGPT, +Cursor, etc.) to help produce a contribution, you must disclose it: + +- Tick the AI-usage box in the pull-request template and name the tool(s). +- Mark AI-authored commits with a trailer, e.g. + `Co-Authored-By: Claude ` or `Assisted-By: `. +- You remain responsible for every line you submit: review it, understand it, and + make sure it is correct and that you have the right to contribute it. + +Disclosed AI assistance is welcome. Undisclosed AI-generated contributions are not, +and may be closed. + +## License + +Runic Gateway is licensed under the **GNU General Public License v3.0 or later** +(see [LICENSE.md](LICENSE.md)). By submitting a contribution you agree that it is +licensed under the same terms (inbound = outbound) and that you have the right to +contribute it. diff --git a/README.md b/README.md new file mode 100644 index 0000000..1758fe7 --- /dev/null +++ b/README.md @@ -0,0 +1,102 @@ +# Runic Gateway — Integration Kit + +**How to put a game on a Runic Gateway site.** + +Runic Gateway is a website platform for game communities. Core knows nothing about +any particular game: everything game-specific — routes, tables, pages, navigation, +notifications — arrives as an installable **module**, and an operator installs one +from an admin panel without building anything. [`module-uo`][module-uo] is the +first module and serves an *Ultima Online* shard. This kit is how you write the +second one. + +> ### 🚧 This is a draft +> +> The kit is finished when **someone outside this project builds a working module +> for a new game by following it alone, without reading core's source.** That has +> not happened yet, so treat every chapter as untested on you. If you are that +> person: the places you get stuck are the most valuable thing this repo can +> receive — [tell us][issues], and please say where you left the kit and what you +> did next. + +--- + +## What you are building + +Three things, and the kit is one book rather than a page in three repos because +the reasons live in the joins between them: + +| # | Part | What it is | +| --- | --- | --- | +| 1 | **The website module** | A bundle core loads at boot: server routes, a schema fragment, a prebuilt client chunk, navigation. The bulk of the work, and the only part every module needs. | +| 2 | **The sidecar** | A small service that owns the connection to your game server, and owns the durable copy of what the game said. **Not optional** — see below. | +| 3 | **The game-side plugin** | Whatever runs inside your game and feeds the sidecar, without ever letting the sidecar stall the game. | + +``` + your game server ──dials out──▶ your sidecar ──HTTP + WS──▶ website core + (plugin: bounded (owns the socket, (loads your module, + queue, writer thread) persists to its own serves the pages) + store, then forwards) +``` + +**The website process never opens a connection to a game server.** That is a rule +in the contract ([`MODULE_API.md`][api] §2.7, `MODULE_API_VERSION` 1.4.0), not a +style preference, and chapter 3 is mostly about why. The short version: the +website is the internet-facing process and your game is not; the sidecar persists +before it forwards, so a website that is down or mid-deploy loses nothing; and a +game must never block on a web request. A game that already exposes a +remote-control surface — Rust's RCON over WebSocket, say — needs a *thin* sidecar, +not none. + +## Start here + +1. **[The dry run][dryrun]** — a complete module designed on paper for a second + game, *Rust*, chosen for how little it shares with Ultima Online. Read it first. + It is the shortest honest picture of the whole job, and it names the one thing + the contract cannot do yet. +2. **`template/`** — a module that builds and loads, doing almost nothing. Copy it, + rename it, and you have a running module before you have read a chapter. +3. **The book** — [`book/`](book/), four chapters, in the order the work happens. + +## The one rule this kit follows + +**It never re-specifies a contract.** These documents are normative, and where the +kit and one of them disagree, they win and the kit has a bug: + +| Authority | For | +| --- | --- | +| [`MODULE_API.md`][api] | Everything a module may do: `module.json`, `ctx`, the `register*` calls, the client registry, the UI kit, schema-fragment rules, the loader's obligations. | +| [`MODULE_SYSTEM.md`][system] | Why the module system is shaped this way, and how a module is installed and removed. | +| [`link/PLAN.md`][linkplan] + [`INTEGRATION.md`][linkint] | The shard↔sidecar wire protocol, as one real sidecar implements it. | + +The kit *teaches*: the order to do things in, the reasoning, worked examples, and +the mistakes that cost this project time. Where it must show a member list it +quotes with a pointer rather than copying, because a guide that restates a +contract diverges from it silently — and a reader who follows the divergent copy +gets a module that fails validation for reasons the guide cannot explain. + +## What this repo contains + +``` +book/ the chapters +template/ a module that builds — copy this +scripts/ the checks CI runs over both +``` + +CI clones core at a **pinned commit**, asserts the version the template declares +still matches that core's `MODULE_API_VERSION`, builds the template, and checks +every link in the book. So a change to the contract breaks this repo's build +loudly instead of leaving a chapter quietly wrong. + +## Licence + +GPL-3.0-or-later, like every Runic Gateway repo — see [LICENSE.md](LICENSE.md). +The `template/` directory is meant to be copied and made yours; it carries the +same licence, and so does anything derived from it. + +[module-uo]: https://gitea.whitlocktech.com/RunicGateway/Module-uo +[api]: https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/website/MODULE_API.md +[system]: https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/website/MODULE_SYSTEM.md +[dryrun]: https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/modules/rust-dryrun.md +[linkplan]: https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/PLAN.md +[linkint]: https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/INTEGRATION.md +[issues]: https://gitea.whitlocktech.com/RunicGateway/Integration-kit/issues diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..15418cf --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,76 @@ +# Security Policy + +Thank you for helping keep Runic Gateway and its users safe. + +## Reporting a vulnerability + +**Please do not report security vulnerabilities through public issues, pull +requests, or the wiki.** A public report tips off attackers before a fix is +available. + +Instead, report privately by email to: + +**whitlocktech@gmail.com** + +Please include as much of the following as you can: + +- The repository and component affected. +- The type of issue (e.g. authentication bypass, injection, secret exposure, + remote code execution, denial of service). +- Step-by-step instructions to reproduce, and a proof-of-concept if you have one. +- The impact — what an attacker could do with it. +- Any suggested remediation. + +You will receive an acknowledgement of your report, typically within a few days. We +will keep you informed as we investigate and work toward a fix, and we are happy to +credit you in the release notes once the issue is resolved (let us know if you would +prefer to remain anonymous). + +## What this repo is, for scoping purposes + +This repo is **documentation plus a template module**. It runs nothing, listens on +nothing, and stores no data. Two kinds of report are still in scope here, and both +are worth sending: + +- **The template teaches an insecure pattern.** It is meant to be copied, so a + weakness in it propagates into every module written from it — an unparameterised + query, a route missing an authorisation check, a secret handled in the clear, a + permissive CORS or CSP suggestion. Treat the template as production code that has + not been deployed yet. +- **A chapter teaches something dangerous.** Advice that would lead a reader to + expose their game server to the internet, hold a secret unencrypted, bypass core's + authorisation middleware, or weaken session handling is a security issue in this + repo even though no code here does it. + +A defect in core, a module or the sidecar itself belongs to that repo: +[`website`](https://gitea.whitlocktech.com/RunicGateway/website), +[`Module-uo`](https://gitea.whitlocktech.com/RunicGateway/Module-uo), +[`link`](https://gitea.whitlocktech.com/RunicGateway/link). + +## Three things that are policy, not oversight + +A module author reading this kit should know these up front, because they shape what +counts as a vulnerability anywhere in this project: + +- **The module boundary is not a security boundary.** A module runs in the same Node + process as core, with the same privileges, against the same database. It is a + code-organisation and distribution boundary. Installing a module is the same trust + decision as installing the site — which is why installation is admin-only. "A + module could reach core's internals" is not a vulnerability report; "an + unprivileged user can install or enable a module" very much is. +- **Access control lives in core.** Route protection is core's middleware, and what + a visitor may see of live game state is the website's admin-toggleable visibility + framework. A module route that reaches game data without going through those is a + security bug. A sidecar that makes its own access-control decisions is a design + error — it is a forwarder. +- **The website process never connects to a game server.** The game is not + network-reachable; it dials out to a sidecar, and only the website's backend talks + to that sidecar. This is a rule in the module contract + ([`MODULE_API.md`](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/website/MODULE_API.md) + §2.7), and a chapter or template that leads someone to break it is the kind of + report this repo most wants. + +## Supported versions + +This project is developed continuously and does not maintain long-term release +branches. Fixes land on `main`; please read a recent copy. diff --git a/book/README.md b/book/README.md new file mode 100644 index 0000000..104f8f4 --- /dev/null +++ b/book/README.md @@ -0,0 +1,99 @@ +# The book + +Four chapters, in the order the work happens. **None of them are written yet** — +this is the outline, landed first so the shape can be argued with before the prose +exists. Chapter status is in the table; a chapter that is not there yet is not +there yet, rather than a stub that reads like an answer. + +Read [the dry run][dryrun] before any of them. + +| # | Chapter | File | Status | +| --- | --- | --- | --- | +| 1 | Your first module in twenty minutes | `01-first-module.md` | not written | +| 2 | The website module | `02-website-module.md` | not written | +| 3 | The sidecar | `03-sidecar.md` | not written | +| 4 | The game-side plugin | `04-game-plugin.md` | not written | + +They are named but not linked on purpose: a link to a file that does not exist is +the thing this repo's link check is for, and an outline should not be the first +thing to fail it. + +## 1. Your first module in twenty minutes + +Copy `template/`, rename it, build it, install it, see a page. No theory. The point +is to reach a working module before learning anything, so that everything after it +is a change to something that already runs rather than a step toward something that +might. + +- What the pieces of `template/` are, one paragraph each. +- `module.json`: the fields you must change, and `coreApi`. +- Building the client chunk. Why a module ships **prebuilt** and an operator never + builds anything. +- Installing it: the admin panel, the `MODULES` environment variable, or a directory + on the volume. +- Reading the state your module lands in, and the four ways it can fail to load. + +## 2. The website module + +The bulk of the kit. + +- **`module.json`** — every field, and which are load-bearing at boot. +- **The server entry point.** `register(ctx, api)`; what `ctx` hands you and why + each member is handed rather than imported; the lazy-accessor pattern that lets a + ported file keep a file-scope `require`, and the require-order rule that comes + with it. +- **The `register*` calls** — routes per tier, notification streams, announce legs, + post hooks, extension slots. Worked examples of each, with the distinctions that + are easy to get wrong (a leg is one-shot delivery with retry; a post hook is + idempotent state that also runs on delete). +- **The schema fragment.** Idempotent, replayed every boot, leading-verb allowlist, + the table-prefix rule, and why there is no migration runner anywhere in this + project. What belongs in `purge.sql` instead. +- **The client half.** The prebuilt ESM chunk; `window.__rg`; the shared-dependency + rule (core owns React and hands it over — a module that resolves its own gets two + Reacts and a broken page); the Vite library build with anchored aliases and + `external: []`, and *why* that combination rather than the obvious one. +- **Routes, nav and features on the client**, and how a module's nav row becomes an + ordinary row an operator can reorder, relabel or hide. +- **The UI kit** — seven members, closed on purpose. What to do about the eighth + thing you want. +- **The OpenAPI fragment**, and how to generate it from your own registrations. +- **Packaging and release CI**: the tarball, the install manifest, the checksum, + and the version living in `module.json`. +- **Boundaries.** What a module must not do, each with the failure it prevents. + +## 3. The sidecar + +Why it exists, why it is **not optional**, and what "thin" means for a game that +already speaks a remote-control protocol. + +- The invariant: your game is never network-reachable; it **dials out**, the + sidecar listens, and only the website's backend talks to the sidecar. +- **Persist before you forward.** The sidecar owns the durable copy — event + history, the latest snapshot of every board, whatever a page must still be able + to render when the game or the website is down. A live feed is allowed to be + lossy *because* the store is not. +- The wire as a **versioned compatibility contract** rather than a build + dependency: a version on every response, a mismatch refused rather than + mis-parsed, and what a bump obliges you to change in the same commit. +- Auth, and why the sidecar is the only exposed part. +- `uo-link` as the worked example, and what a *thin* sidecar for an RCON-style game + keeps and drops. + +## 4. The game-side plugin + +The chapter with the least code and the highest stakes: a plugin that gets this +wrong takes the game down when the sidecar wedges. + +- **Never block the game thread.** Enqueue and return; a bounded, drop-oldest queue; + a dedicated writer thread that drains it. Dropping the oldest event is correct, + and stalling the game to avoid it is not. +- **Read the world only on the game's own thread**, and hand plain data to the + writer. +- Reconnect, backoff, and what to send on connect so the sidecar can rebuild its + picture without asking. +- What to emit at all: the difference between an event stream and a state snapshot, + and why both exist. +- `servuo-plugins` as the worked example. The constraints are general; the C# is not. + +[dryrun]: https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/modules/rust-dryrun.md diff --git a/ci/core-ref.json b/ci/core-ref.json new file mode 100644 index 0000000..718f0e3 --- /dev/null +++ b/ci/core-ref.json @@ -0,0 +1,20 @@ +{ + "repo": "https://gitea.whitlocktech.com/RunicGateway/website.git", + "branch": "edge", + "ref": "c3120ea3daee579ea9948e9e466065f25ee4e92f", + "why": [ + "The core this kit is written against, pinned to a commit rather than a branch.", + "Moving this pin is the moment someone re-reads the chapters: CI asserts the", + "version template/module.json declares still equals this core's", + "MODULE_API_VERSION, so a contract bump turns this repo red on purpose", + "(MODULE_SYSTEM.md 2.11.1 d2, 2.10).", + "", + "The branch is `edge`, not `main`, and that is not a mistake: the module system", + "has not cut over yet and core's `main` has no server/src/modules/ at all", + "(MODULE_SYSTEM.md decision 11). This pin is one of the things that cutover has", + "to revisit.", + "", + "Same convention as Module-uo's ci/core-ref.json, deliberately - one file, one", + "sha, reviewable in a diff." + ] +} diff --git a/scripts/checkCoreApi.js b/scripts/checkCoreApi.js new file mode 100644 index 0000000..3b655f4 --- /dev/null +++ b/scripts/checkCoreApi.js @@ -0,0 +1,80 @@ +#!/usr/bin/env node +// The kit declares exactly one contract version, in `template/module.json`'s +// `coreApi` — the same field a reader copies. This asserts it still names the +// version the pinned core actually exports. +// +// WHY EQUALITY AND NOT "SATISFIES": a range check is what CORE does at load time, +// and it is right there — a module built against 1.4.0 should keep loading into +// 1.5.0. It is the wrong question here. This kit's job is to be *current*: if core +// moved to 1.5.0, `^1.4.0` still satisfies, the build stays green, and nobody ever +// re-reads the chapters. Green would mean "the template still loads", when what we +// need it to mean is "someone has looked at this since the contract changed". +// +// So the failure is deliberate and expected on every core bump, and the fix is a +// human reading the book — not a version string. +// +// Usage: node scripts/checkCoreApi.js --core + +const fs = require('fs') +const path = require('path') + +const ROOT = path.resolve(__dirname, '..') + +function arg(name) { + const i = process.argv.indexOf(name) + return i === -1 ? null : process.argv[i + 1] +} + +const corePath = arg('--core') +if (!corePath) { + console.error('usage: node scripts/checkCoreApi.js --core ') + process.exit(2) +} + +const manifestPath = path.join(ROOT, 'template', 'module.json') +if (!fs.existsSync(manifestPath)) { + // Slice 0 landed this check before the template it checks. Not an error: the + // workflow guards on the same file, and this message is what a local run says. + console.log('checkCoreApi: no template/module.json yet — nothing to check') + process.exit(0) +} + +const versionFile = path.resolve(corePath, 'server/src/modules/version.js') +if (!fs.existsSync(versionFile)) { + console.error(`checkCoreApi: ${versionFile} does not exist.`) + console.error(' Either --core does not point at a website checkout, or the pin in') + console.error(' ci/core-ref.json names a ref with no module system in it (core `main`') + console.error(' has none until the cutover — see that file).') + process.exit(1) +} + +// Core's version.js is a plain CommonJS module with no dependencies, so it can be +// required straight out of an uninstalled checkout. +const { MODULE_API_VERSION: core } = require(versionFile) + +const declared = String(JSON.parse(fs.readFileSync(manifestPath, 'utf8')).coreApi || '') +// A `coreApi` is a RANGE (`^1.4.0`); the version it is built on is its base. +const base = declared.replace(/^[\^~>=<\s]+/, '').trim() + +if (!base) { + console.error(`checkCoreApi: template/module.json declares no coreApi (got ${JSON.stringify(declared)})`) + process.exit(1) +} + +if (base !== core) { + console.error('checkCoreApi: the kit is written against a different core than it is pinned to.') + console.error('') + console.error(` template/module.json coreApi = ${declared} (base ${base})`) + console.error(` pinned core MODULE_API_VERSION = ${core}`) + console.error('') + console.error(' This is the anti-rot check firing, not a broken build. Someone has to:') + console.error(' 1. read MODULE_API.md §1.1 for what changed in the new version;') + console.error(' 2. read the book and the template for anything that is now untrue;') + console.error(' 3. update template/module.json and ci/core-ref.json together.') + console.error('') + console.error(' Bumping the two files without doing step 2 is the one way to make this') + console.error(' check worthless.') + process.exit(1) +} + +console.log(`checkCoreApi: coreApi ${declared} matches the pinned core's ${core} — OK`) diff --git a/scripts/checkLinks.js b/scripts/checkLinks.js new file mode 100644 index 0000000..0596908 --- /dev/null +++ b/scripts/checkLinks.js @@ -0,0 +1,174 @@ +#!/usr/bin/env node +// Every relative link in this repo's markdown must resolve to a file that exists, +// and every in-page anchor must match a heading in the file it points at. +// +// WHAT THIS DOES NOT DO: fetch anything. External URLs are not checked, on +// purpose. This kit points at a self-hosted Gitea, so an HTTP check would fail on +// a runner without credentials, flake when the host is busy, and teach us to +// ignore red. What breaks in practice is a relative path after a file moves, and +// that is answerable offline with certainty. +// +// It DOES check that every http(s) link into the RunicGateway host names a +// branch, because `.../src/branch/main/...` survives and `.../src/commit//...` +// pins a reader to a snapshot of a document we want them reading the current +// version of. +// +// Usage: node scripts/checkLinks.js (repo root) +// node scripts/checkLinks.js --quiet + +const fs = require('fs') +const path = require('path') + +const ROOT = path.resolve(__dirname, '..') +const QUIET = process.argv.includes('--quiet') + +// Directories that hold no prose we own. +const SKIP_DIRS = new Set(['.git', 'node_modules', 'dist']) + +/** Every markdown file in the repo, repo-relative, sorted. */ +function markdownFiles(dir = ROOT, out = []) { + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + if (entry.isDirectory()) { + if (SKIP_DIRS.has(entry.name)) continue + markdownFiles(path.join(dir, entry.name), out) + } else if (entry.name.toLowerCase().endsWith('.md')) { + out.push(path.relative(ROOT, path.join(dir, entry.name)).split(path.sep).join('/')) + } + } + return out.sort() +} + +// Fenced code blocks are stripped before links are read: a fence can legitimately +// contain a path that does not exist (a directory listing of a project the reader +// has not created yet), and flagging those would make the check useless in exactly +// the document type this repo is made of. Stripped by walking lines and toggling +// on a fence marker, rather than by regexp — a fence's own content can contain +// anything, including a line that looks like the end of one. +function stripFences(text) { + const out = [] + let fence = null + for (const line of text.split(/\r?\n/)) { + const m = /^\s*(```+|~~~+)/.exec(line) + if (fence) { + if (m && m[1][0] === fence[0] && m[1].length >= fence.length) fence = null + out.push('') + continue + } + if (m) { + fence = m[1] + out.push('') + continue + } + out.push(line) + } + return out.join('\n') +} + +/** Inline `[text](target)` links and `[ref]: target` definitions, with line numbers. */ +function linksIn(text) { + const found = [] + const lines = stripFences(text).split(/\r?\n/) + lines.forEach((line, i) => { + // Skip inline code spans: `[a](b)` inside backticks is an example, not a link. + const bare = line.replace(/`[^`]*`/g, '') + for (const m of bare.matchAll(/\[[^\]]*\]\(([^)\s]+)(?:\s+"[^"]*")?\)/g)) { + found.push({ target: m[1], line: i + 1 }) + } + const def = /^\s{0,3}\[[^\]]+\]:\s*(\S+)/.exec(bare) + if (def) found.push({ target: def[1], line: i + 1 }) + }) + return found +} + +/** GitHub/Gitea-style heading slugs, for anchor checking. */ +function anchorsIn(text) { + const slugs = new Set() + for (const line of stripFences(text).split(/\r?\n/)) { + const m = /^\s{0,3}#{1,6}\s+(.*?)\s*#*\s*$/.exec(line) + if (!m) continue + const slug = m[1] + .replace(/`/g, '') + .replace(/\[([^\]]*)\]\([^)]*\)/g, '$1') + .toLowerCase() + .replace(/[^\w\s-]/gu, '') + .trim() + .replace(/\s+/g, '-') + if (slug) slugs.add(slug) + } + return slugs +} + +const files = markdownFiles() +const anchorCache = new Map() +function anchorsOf(relPath) { + if (!anchorCache.has(relPath)) { + anchorCache.set(relPath, anchorsIn(fs.readFileSync(path.join(ROOT, relPath), 'utf8'))) + } + return anchorCache.get(relPath) +} + +const problems = [] +let checked = 0 + +for (const file of files) { + const text = fs.readFileSync(path.join(ROOT, file), 'utf8') + const selfAnchors = anchorsIn(text) + + for (const { target, line } of linksIn(text)) { + const where = `${file}:${line}` + + if (/^(mailto:|tel:)/i.test(target)) continue + + if (/^https?:\/\//i.test(target)) { + checked++ + // Not fetched — but a permalink to a moving document is still wrong. + if (/gitea\.whitlocktech\.com\/.*\/src\/commit\//.test(target)) { + problems.push(`${where}: links to a commit snapshot, not a branch — ${target}`) + } + continue + } + + if (target.startsWith('#')) { + checked++ + const slug = decodeURIComponent(target.slice(1)).toLowerCase() + if (!selfAnchors.has(slug)) problems.push(`${where}: no heading matches ${target}`) + continue + } + + checked++ + const [rawPath, rawAnchor] = target.split('#') + // A PR/issue template's text is INLINED into a pull request or issue body, and + // Gitea resolves relative links in those against the repo root — not against + // `.gitea/`, where the file itself lives. So `[CONTRIBUTING.md](CONTRIBUTING.md)` + // is correct in a template and would be wrong anywhere else. Resolve those from + // the root, or this check reports every template link as broken and gets muted. + const base = file.startsWith('.gitea/') ? ROOT : path.dirname(path.join(ROOT, file)) + const resolved = path.resolve(base, decodeURIComponent(rawPath)) + const rel = path.relative(ROOT, resolved).split(path.sep).join('/') + + if (rel.startsWith('..')) { + problems.push(`${where}: points outside the repo — ${target}`) + continue + } + if (!fs.existsSync(resolved)) { + problems.push(`${where}: no such file — ${target}`) + continue + } + if (rawAnchor && resolved.toLowerCase().endsWith('.md')) { + const slug = decodeURIComponent(rawAnchor).toLowerCase() + if (!anchorsOf(rel).has(slug)) { + problems.push(`${where}: ${rawPath} has no heading matching #${rawAnchor}`) + } + } + } +} + +if (problems.length) { + console.error(`checkLinks: ${problems.length} problem(s) in ${files.length} file(s):\n`) + for (const p of problems) console.error(` ${p}`) + process.exit(1) +} + +if (!QUIET) { + console.log(`checkLinks: ${checked} link(s) across ${files.length} markdown file(s) — OK`) +}