From cffd525bdf7bd8651758b15b873a63494efc3e5d Mon Sep 17 00:00:00 2001 From: wtclaude Date: Wed, 12 Aug 2026 09:47:22 -0500 Subject: [PATCH] =?UTF-8?q?docs:=20scaffold=20the=20Integration=20Kit=20?= =?UTF-8?q?=E2=80=94=20front=20page,=20outline,=20and=20the=20checks?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 5 slice 0 (MODULE_SYSTEM.md §2.11.1). The repo's governance, the front page, the book's outline, and the CI that keeps the whole thing from rotting. README.md What the reader is building, all three parts, and the draft banner: the kit is finished when someone outside this project builds a working module by following it alone, and that has not happened. Says the sidecar rule plainly (MODULE_API.md §2.7) rather than leaving it to chapter 3, because a reader who skims the front page and starts coding should still get that one right. book/README.md The outline of four chapters, landed before the prose so the shape can be argued with. Chapters are named but NOT linked — a link to a file that does not exist is what the link check is for, and an outline should not be the first thing to fail it. CONTRIBUTING.md The rule that governs every change here: the kit never re-specifies a contract. Also the prose conventions, and why the pinned ref points at core's `edge` rather than `main`. SECURITY.md Scoped for a repo that runs nothing: the two things that ARE reportable are a template that teaches an insecure pattern (it is meant to be copied) and a chapter that teaches something dangerous. scripts/checkLinks.js Relative links resolve; anchors match a real heading; no link pins a reader to a commit snapshot of a moving document. Nothing is fetched — a self-hosted Gitea would fail on a credential-less runner and teach us to ignore red. Fences and code spans are stripped by a line walk, not a regexp. Its first run found a real one: a PR template's relative links resolve from the REPO ROOT, because that is where their text ends up when Gitea inlines them into a pull request body. Encoded, with the reason. scripts/checkCoreApi.js The anti-rot check. Asserts template/module.json's `coreApi` EQUALS the pinned core's MODULE_API_VERSION — equality, not "satisfies", because a range check stays green across a contract bump and green would then mean "the template still loads" instead of "someone has re-read the book". Both failure branches and the pass were exercised against a real core checkout. ci/core-ref.json The pin, same convention as Module-uo's. Points at `edge`: core's `main` has no server/src/modules/ until the cutover, and that pin is one of the things the cutover has to revisit. .gitea/workflows/pr-checks.yml Two jobs. `links` always runs; `template` is conditional on template/module.json existing, so the repo is gated now and the job arms itself when slice 1 lands, with no edit to the workflow. Same guard Module-uo used through its planning phase. Co-Authored-By: Claude --- .gitea/ISSUE_TEMPLATE/bug_report.md | 45 ++++++ .gitea/ISSUE_TEMPLATE/config.yaml | 8 ++ .gitea/ISSUE_TEMPLATE/feature_request.md | 39 +++++ .gitea/PULL_REQUEST_TEMPLATE.md | 33 +++++ .gitea/workflows/pr-checks.yml | 138 ++++++++++++++++++ .gitignore | 42 ++++++ CONTRIBUTING.md | 135 ++++++++++++++++++ README.md | 102 +++++++++++++ SECURITY.md | 76 ++++++++++ book/README.md | 99 +++++++++++++ ci/core-ref.json | 20 +++ scripts/checkCoreApi.js | 80 +++++++++++ scripts/checkLinks.js | 174 +++++++++++++++++++++++ 13 files changed, 991 insertions(+) create mode 100644 .gitea/ISSUE_TEMPLATE/bug_report.md create mode 100644 .gitea/ISSUE_TEMPLATE/config.yaml create mode 100644 .gitea/ISSUE_TEMPLATE/feature_request.md create mode 100644 .gitea/PULL_REQUEST_TEMPLATE.md create mode 100644 .gitea/workflows/pr-checks.yml create mode 100644 .gitignore create mode 100644 CONTRIBUTING.md create mode 100644 README.md create mode 100644 SECURITY.md create mode 100644 book/README.md create mode 100644 ci/core-ref.json create mode 100644 scripts/checkCoreApi.js create mode 100644 scripts/checkLinks.js 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`) +} -- 2.49.1