Compare commits

...

17 Commits

Author SHA1 Message Date
e9ca759227 Merge pull request 'feat(kit): the engagement contract, taught and built — cutover 5 of 7' (#9) from feat/engagement-contract into main
Reviewed-on: #9
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-09-01 18:03:52 +00:00
a8fa524263 feat(kit): the engagement contract, taught and built (cutover 5 of 7)
All checks were successful
PR Checks / prose (pull_request) Successful in 7s
PR Checks / template (pull_request) Successful in 31s
The kit was pinned to website 963d734 -- MODULE_API 1.6.0, the Teams cutover --
and the platform is on 1.9.0. Three registrations and two calls arrived in
between, and a reader building against this book would have found no mention of
any of them: a module can now declare what its game can announce, and never who
is told.

Moving `ci/core-ref.json` is the mechanism for exactly this. The pin is now
66bb3b9a (website `main`, the engagement cutover) and `template/module.json`
declares `^1.9.0`.

What chapter 2 gained, under "Telling core something happened":

  * a TRIGGER is a payload contract, not a notification stream -- the two share
    one id namespace and are constantly confused;
  * `ceiling` is required, has no default, and is a CONTAINMENT tree rather than
    a size ladder (a `staff` ceiling does not permit `owner`);
  * an AUDIENCE resolver returns user ids and nothing else, resolves to NOBODY
    on failure, and takes CONSTANT params -- the constraint worth knowing before
    you design around it;
  * templates re-ensure per seedVersion, rule groups are offered ONCE per group
    key, so a rule appended to an existing group reaches fresh installs only;
  * `ctx.events.emit` binds the owner and is fire-and-forget; `ctx.inbox.push`
    is the direct write, for when there is nothing for an operator to decide.

The template builds all of it: one trigger, one audience over the clan roster it
already had, one seeded body and one seeded rule group, and an emitter in
`boot.js` that fires on the TRANSITION rather than on the poll. Seven new tests,
including the audience that resolves to nobody when its query throws.

Three claims were wrong and are corrected here rather than shipped:

  * core validates `subjectKey` against the declared variables and refuses the
    module; the draft taught a cooldown keyed on `undefined`, which the check
    exists to prevent and a reader will never see.
  * `emit` throws OUTSIDE production and only drops-and-logs inside it. Teaching
    the second half alone leaves a developer meeting a throw the book says
    cannot happen.
  * the seeded body itself was malformed -- heading `level: 2` where the block
    registry takes 'h2', and no block ids at all.

The third is the one worth keeping: `registerEngagementSeeds` checks that
`blocks` is a non-empty array and stops, so that body would have registered,
seeded, and failed the first time an operator opened it. Found by running the
template's `register()` through core's real registry at the pinned ref -- which
CI does not do, and cannot: the template job checks the version and runs the
template against fakes. A fake accepts what core refuses. The gap is now named
in the chapter, beside the code, and in the pin's own comment, and the rule that
bit has a test that fails on it.

Also: `checkLinks` skipped `.core/`. Bumping this pin means cloning core into
that directory first, and the walk then reported nine broken links in someone
else's README. CI never saw it -- the clone happens in the `template` job and
the check runs in `prose` -- so it was a failure only a person could meet.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-09-01 12:57:55 -05:00
39736f8448 Merge pull request 'docs(book): teach the derived release version, and move the template onto it' (#8) from docs/release-cadence into main
Reviewed-on: #8
2026-08-19 18:06:38 +00:00
3979fa5abf docs(book): teach the derived release version, and move the template onto it
All checks were successful
PR Checks / prose (pull_request) Successful in 7s
PR Checks / template (pull_request) Successful in 33s
The template shipped the declared-version release engine the reference module
has just abandoned: publish when a push to `main` leaves `module.json` at a
version with no release yet. Both flavours of the workflow move to the engine
`link`, `installer` and now Module-uo run - feat!/BREAKING CHANGE -> major,
feat -> minor, fix|perf -> patch - with `module.json` kept as a floor and a
`workflow_dispatch` backdoor for a manifest change with no releasable code
behind it. The tag is the number that ships, and the job writes it into the
`module.json` inside the bundle.

The chapter keeps the declared model in view rather than deleting it, because
the reason it was abandoned is the part a reader needs: its cost is paid on
every release, and the drift it prevents is something review catches anyway. A
week of merged work in the reference module produced no bundle at all.

Also carried over from the same pass: a tag pushed without a release behind it
is recovered instead of standing down forever, and the changelog moved into the
plan step (so assemble clears `$OUT`, not `dist/`).

Kept: the `# CHANGE THESE` banner, the exclusion list from the acceptance run's
F4, and the GitHub twin's `MODULE_SOURCE_HOSTS` note.

checkLinks, checkRenameSites and checkChapterPaths pass.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-19 13:01:51 -05:00
67aff992a4 Merge pull request 'docs(book): a mod is a plugin too, and RCON is not the Rust answer' (#7) from docs/no-rcon-example into main
Reviewed-on: #7
2026-08-19 11:15:28 +00:00
d497a3b09a docs(book): name Oxide, and the question "how many sidecars" answers
All checks were successful
PR Checks / prose (pull_request) Successful in 8s
PR Checks / template (pull_request) Successful in 28s
Follows the org lead's two corrections on docs#170. The Rust example is an OXIDE
plugin - naming the framework is the difference between a design a reader can
start from and one they have to go and choose for themselves - and the
architecture pairs one sidecar to one game server, on that server's own host.

Chapter 3 gains the general form of that second one, since it is the chapter
where a reader decides what to build: if your game runs as a fleet, "how many
sidecars" is answered by where the loopback boundary is, not by how many
processes you would rather run. Your module holding several clients is the
cheaper end of that trade, and core never learns there is more than one.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-19 04:16:48 -05:00
744e5b7944 docs(book): a mod is a plugin too, and RCON is not the Rust answer
All checks were successful
PR Checks / prose (pull_request) Successful in 7s
PR Checks / template (pull_request) Successful in -28s
The kit taught, to an audience outside this org, that Rust needs "no game-side
plugin to write at all" because it ships RCON. That is overruled: the Rust dry
run reaches the game through a MOD - a plugin loaded by the server's own
framework, hooking events and dialling out - exactly as the ServUO overlay does
(docs#170).

Chapter 3's section kept its question and lost its example, which turned out to
improve it. The useful test is not "does my game expose a protocol" but "does it
DELIVER EVENTS": a remote-control channel is built for an operator typing
commands and tells you what you asked about, when you ask, and a website needs
what happened whether or not anyone was listening. A channel that answers
questions can only be polled, and polling turns "someone left the clan at 14:02"
into "the count was different at 14:03".

Rust now appears in that section as the counter-example rather than the example,
and carries the finding that is actually worth having: its server is a BINARY
where ServUO is source you compile, and the three-part shape survives that
unchanged. The plugin-dials-out arrangement is not a property of having source
access.

Chapter 4 said a game with a remote-control protocol may not need any of it, and
that its worked example is source you build. Both now say what is true - the
rules in that chapter are properties of being inside a game loop, and apply
identically to a mod in a closed server.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-19 04:11:03 -05:00
622abe9ed5 Merge pull request 'feat(kit): the two shapes Teams added, taught and built (Teams phase 11)' (#6) from feature/teams-phase11 into main
Reviewed-on: #6
2026-08-19 09:03:38 +00:00
1c7d6151c7 ci(core-ref): pin to the Teams cutover, where 1.6.0 reached main
All checks were successful
PR Checks / prose (pull_request) Successful in 11s
PR Checks / template (pull_request) Successful in 36s
The two mechanical lines the phase owed. template/module.json already declares
^1.6.0; this is the sha that makes checkCoreApi agree with it, which it could
not do until the cutover landed - the check is an equality against a core on
main, and 1.6.0 had only ever been on edge.

The `why` block records what the check bought this time rather than just what it
is for. Writing the chapters against 1.6.0 is what found that core's inverted
slot fills named three of module-uo's slots literally, so the direction worked
for that one module and silently did nothing for any other game. A book written
for an audience outside this org is exactly the instrument that finds that, and
it was fixed in core before this pin moved.

Also: the pin skipped the Teams `edge` entirely. The kit is written against what
shipped, never against what is in flight.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-19 04:02:24 -05:00
4093293009 fix(kit): what installing the template into a real core showed
The template was built into a running core - MariaDB, the real loader, a browser
- and both halves of the walk passed: core reconciled two Teams out of the
provider on the first boot, /public/teams/<slug>/members came back with
projected:true, and the clan page rendered core's activity feed and forum in the
two slots this module declared. That last one is the whole point of the phase: a
module whose id is not "uo" now gets core's Team content, which is what
website#160 fixed. module-uo's own guild page was walked on the same core and is
unchanged.

Two things the walk found, both of the kind only a browser can:

PageHeader takes `lead`, not `subtitle`. The template has been passing subtitle
since it was written, and an unknown prop on a React component is silently
dropped - so every page built from this template rendered its heading with
nothing under it, on a site where every core page has a line there. Nothing warns
anywhere. Fixed on all three pages, and chapter 2 now says to check prop names
against 3.4 rather than guessing them, beside the paragraph about `shell` that
exists for exactly the same reason.

"1 members" on the clan list.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-19 01:31:16 -05:00
7875848ee7 feat(kit): the two shapes Teams added, taught and built
MODULE_API 1.6.0 expands the contract this book teaches against, so the book
owes two shapes and one correction. Chapter 2 gains both and the template grows
a working version of each, because a reader following a snippet has no way to
find out whether it runs.

ONE SENTENCE WAS WRONG. Chapter 2 said, of extension slots, "Only core may
declare a slot; a module may only fill one". 1.6.0 inverted exactly that: a
module declares a place on its OWN page and core fills it. That is not a stale
detail - a new game's module cannot implement Teams at all without the inverted
direction, so it is the shape the reader needs and did not have.

THE TWO SHAPES

- The inverted slot. A new "Slots go the other way too" section: why the
  direction has to invert (core owns the Team, not the word for one), the
  namespace rule, one slot per PLACE, the optional { core } naming which of
  core's three contributions goes there, and why asking for one core does not
  offer throws when almost everything else in that registry fails open.

- registerTeamProvider, in "Becoming the source of Teams". The first
  registration where core calls YOU and waits, which is where every rule in it
  comes from: the envelope, the ten-second budget, refusing as a normal answer,
  and the one mistake worth naming - answering with an empty list because the
  game is unreachable, which core reads as authoritative and acts on.
  projectRoster gets its own treatment because it is the exception that fails
  CLOSED. pageUrlTemplate is a footnote beside it, as intended.

WHAT THE TEMPLATE GREW

model/clans/ - the provider over two tables, with the guards that matter: an
unreachable game refuses rather than reporting no clans, an empty roster is
refused unless the game says the clan is empty (which is why the schema keeps a
member count the rows cannot supply), and the audience rule lives in one file
that both projectRoster and the module's own page consult, because a second copy
drifts in the direction that publishes what core is withholding.

Its own /clans routes, deliberately not /teams - core mounts that itself, and
the loader would refuse the collision. A clan list page and a clan page that
declares three slots for core.

12 provider tests and three registration tests, 47 server and 20 client in
total. The purge test finally proves something: two of the three tables are now
a parent and its child.

WHAT IT DOES NOT DO. Enumerate the contract. The kit teaches one path end to end
and links out; it has never mentioned three pre-Teams registrations and that is
the design, not a gap.

FOUND WHILE WRITING IT: core filled three literal uo.guild.* slot names, so the
inverted direction reached exactly one module and every other game's page came
up empty with nothing logged. Fixed in website#160 / Module-uo#15 / docs#165
before this chapter could teach it - which is what this phase is for.

The ci/core-ref.json pin moves in a later commit on this branch: checkCoreApi is
an equality against a core on main, and 1.6.0 does not reach main until the
cutover.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-19 01:21:55 -05:00
77418aaef5 Merge pull request 'chore(ci): the pinned core is on main now, not edge' (#5) from chore/core-pin-on-main into main
Reviewed-on: #5
2026-08-12 23:07:42 +00:00
8fa4210477 chore(ci): the pinned core is on main now, not edge
All checks were successful
PR Checks / prose (pull_request) Successful in 7s
PR Checks / template (pull_request) Successful in 26s
The module system cut over on 2026-08-12 and website's `edge` branch was
deleted, so `ci/core-ref.json` named a branch that no longer exists.

The sha did not move. The pinned commit is an ancestor of `main`, the contract
is still MODULE_API_VERSION 1.5.0, and no chapter changed - this is a label
correction, not a re-pin, and deliberately not the moment d2 exists to create.

Nothing in CI reads the `branch` field: the workflow clones the repo and checks
out the sha, which is both why the cutover could not break the build and why a
wrong label here would have sat unnoticed indefinitely. The field is for the
person deciding whether a newer core is worth re-reading the book for, and a
branch that no longer exists tells them nothing. The `why` block now says which
half is load-bearing.

checkCoreApi.js's not-a-core error also asserted that core `main` "has none
until the cutover", which stopped being true at the same merge. A reader hitting
that message would have gone looking for a cutover that already happened - the
kit's own lesson from the CRLF defect, that a failure message naming a diagnosis
has to be right about it, applied to the kit's own scripts.

Verified both paths: the check still passes against a real core (^1.5.0 vs
1.5.0), and the rewritten message renders as intended. checkLinks,
checkRenameSites and checkChapterPaths all clean.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-12 18:05:44 -05:00
abf9344189 Merge pull request 'fix(kit): everything the acceptance run found — Phase 5 slice 3' (#4) from fix/acceptance-findings into main
Reviewed-on: #4
2026-08-12 22:42:49 +00:00
f8f7014d53 fix(kit): everything the acceptance run found — Phase 5 slice 3
All checks were successful
PR Checks / prose (pull_request) Successful in -38s
PR Checks / template (pull_request) Successful in 29s
A cold agent was given this repo and the documents it links to, and nothing
else — no core source, no module-uo — and asked to build a module for a second
game. It did, in one pass. The record is docs/modules/kit-acceptance.md; this is
the repair list, plus the two things it recommended that were not defects.

The one it could not find, because it had no core to render against: a module
page built exactly as this kit teaches renders OUTSIDE the site. PublicLayout is
the chrome, not the body. Core grew an opt-in `shell` prop for it
(MODULE_API_VERSION 1.5.0, website#148); the template passes shell="narrow" and
chapter 2 explains why you name a width and never a class.

Fixed:

- **F1, and the worst of them, because it lands in the first twenty minutes.**
  `npm run check:swagger` failed on a PRISTINE template on Windows: the check
  compared the committed fragment byte-for-byte and a default Windows clone is
  CRLF while the generator writes LF. The message blamed "the routes or their
  annotations". Now `template/.gitattributes` pins `eol=lf` and the comparison
  normalises line endings anyway — a check may only fail for the reason it names,
  and this one names a diagnosis.
- **F3** — `.gitea/workflows/release.yml` carries `gitea.example.com` and
  `your-org/your-module` under a literal `# CHANGE THESE`, was not in the rename
  checklist, and `checkRenameSites.js` could not match it, so CI was silent by
  construction. Row added, pattern widened. (The agent reported both workflow
  flavours; only the Gitea one is affected — GitHub supplies its own variables.
  Corrected in the record.) The near-miss is kept in the check's comments and its
  suite: the obvious widening is `example\.com`, which fires on a fixture URL in
  checkImports.test.js. Every alternative has to be a string that cannot occur by
  accident, which is the same rule that made the id `examplegame`.
- **F4** — the release bundle's include list was hardcoded, so adding
  `server/utils/` would have silently dropped it from every release while the
  bundle check stayed green. Inverted to an exclusion list, in both flavours, and
  run by hand because a release workflow never executes in CI.
- **F5** — the annotation-quoting warning was wrong in both directions, and the
  correction is measured rather than reasoned. A backtick is harmless (the
  template's own description has two spans and they survive). A `"` is not, and
  it does not throw: `'A "quoted" status'` is silently TRUNCATED to `A "` while
  swagger-autogen prints Success and the error capture sees nothing. The only
  signal is check:swagger blaming your routes.
- **F6** — `template/.gitignore`, so a copied template that is `git init`ed
  inherits ignore rules instead of nothing.
- **F7** — the UI kit is eight exports across five rows, not seven. The contract
  said seven and this kit had faithfully carried the miscount out of it.

Adopted, not defects:

- Chapter 1 now says to run every check on the untouched copy first. That is what
  found F1; without a baseline the first failure is ambiguous forever.
- The template ships the §2.7 self-check the agent wrote for itself. The rule has
  no CI in general — an outbound socket is not statically detectable — but a
  module can make a decidable claim about its own tree. Ported from its code with
  a header explaining how to NARROW it when a sidecar client arrives, since
  talking to your sidecar is the expected shape and is not what §2.7 forbids.

The pin moves to website edge 4ad8b2b, the 1.5.0 bump, and template/module.json
declares ^1.5.0 — so checkCoreApi's equality assertion still holds and the
template uses a member that exists only at that ref and later.

32 server + 18 client template tests, 21 kit-script tests, all four checks green.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-12 14:40:02 -05:00
d5a8520ce0 Merge pull request 'docs(book): the four chapters — Phase 5 slice 2' (#3) from docs/book into main
Reviewed-on: #3
2026-08-12 18:38:07 +00:00
f41ff92c67 docs(book): the four chapters — Phase 5 slice 2
All checks were successful
PR Checks / prose (pull_request) Successful in 8s
PR Checks / template (pull_request) Successful in 27s
The book, written out of the tree slice 1 proved. Four chapters in the order the
work happens: the first module in twenty minutes, the website module, the sidecar,
and the game-side plugin.

Shape, settled with the org lead:

  * template/README.md stays the REFERENCE — it travels with a copied template and
    CI holds it against the tree — and chapter 1 is the narration: what you should
    see after each step, the state your module lands in, and the four ways it fails.
    The chapter links to the checklist rather than restating it.
  * chapters 3 and 4 cite link/ and servuo-plugins/ by FILE AND IDENTIFIER, never by
    line. Those repositories move for their own reasons and checkLinks already
    forbids commit permalinks, so a line number in this book is wrong the moment
    they do. The template stays the only code quoted verbatim.
  * one PR: the outline's status table and the link check are only coherent when the
    whole set lands.

scripts/checkChapterPaths.js is the anti-rot half a machine can answer: every path
a chapter names in backticks must exist. None of those mentions is a markdown link,
so checkLinks never looked at them, and none is code, so nothing else did either —
renaming one template file would have left four chapters quietly pointing at
nothing. Its anchor list is STATED rather than derived from the tree, for the reason
the template's own build guard states it: a list derived from what exists cannot
fail when what exists changes, and an anchor that stops matching is a check that has
silently stopped checking. So each anchor must exist or the check fails. Eleven
tests, every "must not catch" case a span that really appears in the book.

stripFences moved to scripts/lib/markdown.js and both checks use it — shared code,
not a shared description.

CHAPTER 1 WAS RUN, NOT REASONED ABOUT. The template was copied into a real core on
edge, booted against the dev database, and every claim in "what you should see"
checked: the five log lines, /examplegame/status with its injected
<script type="module" src="/modules/examplegame/entry.js">, the chunk served
no-cache while module.json 404s, /api/v1/public/world/status, the capabilities in
/api/v1/public/modules, and the route in the merged /api/docs.json. Then the three
failures the chapter tells a reader to cause on purpose, because a chapter that
predicts the wrong debugging heuristic is worse than one that predicts none:

  * an undeclared prefix  -> stage `register`, "declared public/extra but never
    registered it", routes 404 and absent from /public/modules;
  * a table without the id prefix -> stage `schema`, at LOAD time, before mounting;
  * a throwing onBoot -> after mounting, so the same route answers 503 "Module
    unavailable" rather than vanishing.

All three came out exactly as written, and the messages in the chapter are that
core's own. Two small corrections fell out of the run: the log sample now shows the
real interleaving of core's three lines with the module's two, and the section on
failure adds that a module disappears from /api/v1/public/modules in every failure
case — a check that needs no login.

MODULE_SYSTEM.md 2.11.1 slice 2. Docs half: docs#146.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-12 13:20:29 -05:00
47 changed files with 4751 additions and 336 deletions

View File

@@ -15,8 +15,14 @@
# tree, in both directions — an unlisted file that still carries the
# placeholder, and a listed file that no longer does, are both failures. That
# checklist is the only instruction a reader has for the first thing they do
# with the template, and it is prose, so it rots the way prose does. The two
# checks in `scripts/` have their own unit tests, run in the same job.
# with the template, and it is prose, so it rots the way prose does.
#
# And it checks that every path the book names in backticks still exists. The
# chapters teach out of `template/`, none of those mentions is a markdown link,
# and nothing else in this repo would ever look at them — so renaming one
# template file would leave four chapters quietly pointing at nothing. That is
# the cheap half of "is the book still true"; the other half is a reviewer's.
# All three checks in `scripts/` have their own unit tests, run in the same job.
#
# • `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
@@ -86,11 +92,17 @@ jobs:
- name: Check the rename checklist against the template
run: node scripts/checkRenameSites.js
- name: Check every path the book names still exists
run: node scripts/checkChapterPaths.js
# The checks, checked. A check that has never been shown to fail is a check
# nobody knows the state of — and this one gates the instructions for the
# first thing a reader does.
# nobody knows the state of — and these gate the instructions for the first
# thing a reader does. Named file by file rather than `node --test scripts/`:
# directory mode is not portable across the Node versions people run this on.
- name: Test the checks themselves
run: node --test scripts/checkRenameSites.test.js
run: |
node --test scripts/checkRenameSites.test.js
node --test scripts/checkChapterPaths.test.js
template:
runs-on: ubuntu-latest

View File

@@ -43,9 +43,11 @@ in the contract ([`MODULE_API.md`][api] §2.7, `MODULE_API_VERSION` 1.4.0), not
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.
game must never block on a web request. A game that genuinely delivers events on a
surface of its own needs a *thin* sidecar, not none — but check that it delivers
events rather than answering questions, because a channel built for an operator
typing commands can only be polled, and polling turns "someone left at 14:02" into
"the count was different at 14:03".
## Start here
@@ -84,9 +86,14 @@ 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 runs its
guards, checks every link in the book, and holds the template's rename checklist
against the template's own tree. So a change to the contract breaks this repo's
build loudly instead of leaving a chapter quietly wrong.
guards, checks every link in the book, holds the template's rename checklist
against the template's own tree, and checks that every path a chapter names is
still there. So a change to the contract breaks this repo's build loudly instead
of leaving a chapter quietly wrong.
None of that can tell you whether a paragraph has become untrue about a file that
still exists. That is a reviewer's job on every pull request, and a
`MODULE_API_VERSION` bump is when it is owed in full.
## Licence

275
book/01-first-module.md Normal file
View File

@@ -0,0 +1,275 @@
# 1. Your first module in twenty minutes
No theory in this chapter. You will copy a module that already works, rename it,
build it, install it into a running core, and load a page it serves. Everything
after this chapter is a change to something that runs, rather than a step toward
something that might.
That order is deliberate. The module system has a lot of seams — a server entry
point, a client chunk, a schema fragment, a nav registration, an OpenAPI fragment
— and each one is easy to understand and unpleasant to debug in the abstract. Get
all of them working at once with almost no content in them, and you can then break
exactly one at a time on purpose.
**What you need:** a Runic Gateway core you can restart, Node 20 or newer, and
about twenty minutes. You do not need core's source, and you should not read it —
if this chapter cannot be followed without it, that is a bug in this chapter and
[worth telling us about][issues].
---
## The pieces you are about to copy
`template/` is a whole module, in the shape a real one has. Nine things matter and
the rest is filling:
| Piece | What it is |
| --- | --- |
| `template/module.json` | The first thing core reads. Your id, your version, the core API range you need, and a declaration of every prefix you will mount. |
| `template/server/index.js` | The server-side handshake: one exported function, called once with `(ctx, api)`. |
| `template/server/core.js` | Lazy accessors over `ctx`, so the rest of your server code can reach core the way ordinary code reaches a library. |
| `template/server/boot.js` | `onBoot` and `onShutdown` — where anything needing a live database goes. |
| `template/server/db/schema.sql` | Your tables. Idempotent, replayed at every boot. |
| `template/server/db/purge.sql` | The same tables, dropped. Run only when an operator explicitly purges you. |
| `template/client/src/entry.jsx` | The client-side handshake: registers your routes and your nav rows into core's SPA. |
| `template/client/vite.config.js` | The library build that produces the chunk core serves — and the aliases that make your React core's React. |
| `template/swagger-fragment.json` | Generated. Core merges it into its own API documentation. |
Two of those have a reputation. `vite.config.js` is the highest-risk mechanical
detail in the whole system and chapter 2 spends real time on why; `module.json`'s
`mounts` is the one field people fill in wrong and discover at boot. Neither
matters yet — the template has both right.
## Copy it, and make it yours
```bash
cp -r template/ ~/my-module
cd ~/my-module
```
**Run every check on the untouched copy before you change a line.** Jump ahead to
*Build it* and run all of it — the tests, the build, the three guards — on the
template exactly as it arrived:
```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
```
It takes two minutes and it buys you a **baseline**. Every one of those commands
is green on a pristine template, so from here on a red one is something you did —
and you will know which edit did it, because you were green a moment ago. Without
that, the first failure is ambiguous forever: is this my mistake, or was the
template already like this?
That is not a hypothetical. The kit's own acceptance run
([`kit-acceptance.md`][acceptance]) found `check:swagger` failing on an untouched
copy on Windows, with a message that blamed the reader's routes. It is fixed, and
the reason the run *found* it rather than being derailed by it is that it had a
baseline.
Your module id is the single most load-bearing string in it: it is the directory
core loads you from, the key in core's database, the URL segment every one of your
pages hangs under, and the prefix every one of your tables must carry. It must
match `^[a-z][a-z0-9-]{1,31}$`, and you want no hyphen in it unless you enjoy
backticking table names.
Change `id` in `module.json` first, then work down the checklist in
`template/README.md` — it names every file that still carries the placeholder,
and it is [verified by CI][renamecheck] in both directions, so it is not the kind
of checklist that is wrong by the second edit.
**The placeholder is `examplegame`, not `example`, and that is not an aesthetic
choice.** A check for a leftover `example` fires on the phrase "for example" in
ordinary prose, and a check that cries wolf is a check people learn to ignore. If
you build your own checks later, pick placeholder names that cannot occur by
accident.
## Build it
```bash
npm ci --prefix server
npm test --prefix server
npm ci --prefix client
npm run build --prefix client # → client/dist/entry.js
npm test --prefix client
```
Build **before** you run the client 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 actually ships. That ordering has bitten this project
twice in two different repositories, which is why it is called out here rather
than left to a CI file.
What you have now is `client/dist/entry.js` — a prebuilt ES module — and a server
tree that has never been compiled at all, because it does not need to be.
**An operator never builds anything.** That is the constraint the whole delivery
path is designed around: a module arrives as a tarball with the chunk already in
it, and core serves that file untouched. Your build machine is the only place a
bundler ever runs.
## Install it
Three supported ways, and for the next twenty minutes you want the third:
1. **Admin → Modules**, pasting the URL of an install manifest — the JSON your
release workflow publishes beside your tarball. This is how a real operator
installs you.
2. **The `MODULES` environment variable**, `<id>@<version>=<manifest URL>`, for a
deployment that declares its module set instead of clicking it.
3. **A directory on the volume.** Copy your whole module tree to
`<website>/modules/<your-id>/` and restart core.
```bash
cp -r ~/my-module <website>/modules/my-id
# restart core
```
**Copy it. Do not symlink it.** The loader lists directory entries and asks each
whether it is a directory; a symlink answers no, and your module is skipped in
complete silence. This is the single most common way a first install appears to do
nothing at all.
Two more things that look like your module failing and are not:
- If core is running in a container, your files have to be on the volume core sees
— `MODULES_DIR` (`/app/modules` under the shipped Compose file), not the
repository directory next to it.
- A core with a fresh database boots in **maintenance mode**, and public module
pages sit behind the same maintenance gate core's own do. Your page will look
broken while the site is not live yet.
## What you should see
Restart core and read the log. A module that loaded says so:
```
INFO [examplegame] registered {"version":"0.1.0","routes":"public:/world,/clans"}
INFO [modules] registered module "examplegame" v0.1.0 {"mounts":{"public":["/world","/clans"]}}
INFO [modules] schema ensured for module "examplegame" {"statements":4}
INFO [examplegame:boot] booted {"refreshMs":30000}
INFO [modules] module "examplegame" started
```
Your two lines and core's three, interleaved: core narrates each step of your load
in its own `[modules]` namespace, and your logger is namespaced with your id. That
alternation is the quickest way to see how far a load got.
Then, in the browser:
- **`/examplegame/status`** renders your page, with a **World** row in the public
header pointing at it. That row is now an ordinary nav row: an operator can
reorder it, relabel it or hide it from the nav editor exactly as they can core's.
- **`/examplegame/clans`** lists the two clans the template seeds at boot, and one
of them renders at `/examplegame/clans/clan-1` — the page that declares three
places for core to fill. On a core with Teams those hold the activity feed, the
forum and the notification control; on one without, they render nothing and the
page is exactly as complete. Both are correct outcomes and neither logs anything.
- **`/api/v1/public/world/status`** answers JSON, and so does
`/api/v1/public/clans`.
- **`/api/v1/public/modules`** lists you, with the `capabilities` array from your
`module.json`. This is how a client — core's SPA, the Android app, anything —
feature-detects you.
- **`/api/docs`** shows your route under its own tag, merged out of the OpenAPI
fragment you committed. (`/api/docs.json` is the raw merged document, if you
would rather grep it.)
- **Admin → Modules** shows you as `started`.
Open the browser console while you are there. Your entry logs the core API version
it registered against, and any complaint the client half has to make will be sitting
next to it.
## The state your module is in
Core keeps one row per module and its `state` column has five values. Four are
outcomes and one is an operator's decision:
| State | Means |
| --- | --- |
| `installed` | Files are on the volume; the row was just created. |
| `enabled` | Cleared for this boot to try. Every non-disabled row is reset to this at each boot. |
| `started` | Loaded, registered, schema replayed, `onBoot` returned. This is the one you want. |
| `startup_failed` | Something went wrong; the panel shows the stage and the reason. The site came up anyway. |
| `disabled` | An operator switched you off. Nothing else — not a failure, not a reinstall — moves this. |
The important half of that table is what it implies: **a module that fails to
load never takes the site down.** Core try/catches your entire lifecycle, records
where you broke, and serves everything else. You are debugging from an admin
screen, not from a stack trace in a crash loop.
**A retry is a restart.** Every boot resets non-disabled rows to `enabled` and
writes that boot's outcome, so the panel always describes the run you are looking
at rather than a run from last week.
## The four ways it fails
When something is wrong, the shape of the failure tells you where to look before
you read a single message.
**1. Your module is not in the panel at all.** The loader never saw a directory
worth scanning. It is a symlink; or it is in the wrong place; or it has no
`module.json` at the top of it. Note the bundle shape here — a release tarball's
top-level directory is `<name>-<version>`, so an unpacked bundle copied wholesale
leaves core looking at a directory with nothing in it but another directory.
**2. It is `startup_failed`, and your routes and nav are simply absent.** The
failure happened before anything was mounted: a malformed `module.json`, an
unsatisfiable `coreApi`, a prefix that collides with core's, a schema fragment
breaking a rule. Nothing of yours is on the URL surface, so nothing of yours can
half-work.
**3. It is `startup_failed`, and your routes answer `503`.** The failure happened
after mounting — the database rejected a statement in your fragment, or your
`onBoot` threw. Your routes stay mounted deliberately: the URL surface is a
property of what is installed, not of whether a boot hook succeeded on this
machine. A module that failed to warm up says it is down; it does not serve half
its data.
**4. It answers `404` everywhere.** Someone disabled you. Same mechanism — mounted
and guarded, never unmounted.
The panel names the stage each failure happened in, and the stages are the
loader's own validation steps, listed in [`MODULE_API.md`][api] §4.3 and §4.4. Read
the stage first; it is usually enough. Core logs the same thing at boot —
`module "…" failed to load — continuing without it {"stage":…,"reason":…}` — so you
do not need the panel to debug this.
**In all four cases you disappear from `/api/v1/public/modules`.** That endpoint
answers what this backend is *serving*, so a client feature-detecting your
capability renders a site without it rather than one advertising something that
`503`s. It is also a quick check with no login: if you are not in that list, you
are not running, whatever the page looks like.
## What to do next
You have a module. Now break it on purpose, once each, and watch what the panel
says:
- Add a prefix to `module.json`'s `mounts` and do not register it. → stage
`register`, *"declared public/extra but never registered it"*. What you declared
and what you registered must match, in both directions.
- Rename one of your tables so it no longer starts with your id. → stage `schema`,
at **load** time, before anything is mounted: your routes answer `404`.
- Throw inside `onBoot`. → after mounting, so the same route answers `503` with
*"Module unavailable"* instead of vanishing.
Those are the three outcomes above, and the messages are what this core actually
prints for them — they were run to write this paragraph rather than predicted.
Twenty minutes of that is worth more than any chapter, because every one of those
failures is one you will cause accidentally later, and you will recognise it.
Then read [chapter 2](02-website-module.md), which is the same module explained —
what `ctx` hands you and why it is handed rather than imported, what each
`register*` call is for, why the client half is built the way it is, and what a
module must never do.
[api]: https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/website/MODULE_API.md
[issues]: https://gitea.whitlocktech.com/RunicGateway/Integration-kit/issues
[renamecheck]: ../scripts/checkRenameSites.js
[acceptance]: https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/modules/kit-acceptance.md

821
book/02-website-module.md Normal file
View File

@@ -0,0 +1,821 @@
# 2. The website module
The module you built in [chapter 1](01-first-module.md), explained. This is the
longest chapter in the book because the website module is most of the work, and
because almost every part of it is shaped by a constraint that is invisible until
you hit it.
Nothing here is normative. [`MODULE_API.md`][api] is the contract; where this
chapter and the contract disagree, the contract is right and this chapter has a
bug. What is here is the reasoning — which is exactly what a contract cannot carry
without becoming unreadable.
---
## The shape of the whole thing
A module is a directory core reads at boot. Core loads it, hands it two objects,
and takes back whatever it registers.
```
core boots (its own schema and seed have already run)
└─ scans modules/*/module.json
└─ validates yours ← nothing mounted yet: a failure here leaves
│ nothing of yours on the URL surface at all
└─ require(server entry)
└─ register(ctx, api) ← your one synchronous handshake
└─ second pass: mounts everything that survived
└─ replays your schema fragment
└─ onBoot(ctx) ← the first moment a database exists
└─ HTTP listener binds
```
Two properties of that sequence explain most of the rules that follow.
**It is synchronous and it happens during `require`.** Core's route-manifest
generator and its OpenAPI generator both require the app with the database pool
pointed at a dead port — that is how they introspect a real Express app without a
database. So `register()` may not `await` and may not query. A module that did
would hang both build tools, and the symptom would be a CI job that never
finishes rather than an error anyone can read.
**Mounting is a second pass.** Every module is validated before any module is
mounted. If mounting happened inside the scan loop, the first module's layers
would be sitting on the tier router while the second was validated —
indistinguishable from core's own — and the second would be told it collided with
*core*, naming the wrong culprit. You will never see this; it is why the failure
messages you do see are trustworthy.
## `module.json`
Every field is documented in [§2.1][api]. Three of them decide whether your module
loads at all.
**`id`** is the directory core loads you from, the key of your database row, the
URL segment your pages hang under, and the required prefix of every table you
create. It must equal its own directory name — a module renamed by copying it to a
different directory is rejected rather than quietly mounted under a name nothing
else agrees with.
**`coreApi`** is a semver range against core's `MODULE_API_VERSION`. Set it to the
version you developed against and let it drift upward deliberately. This is the
one number that decides whether a module written today loads against a core
shipped next year, and a range that is too loose does not fail — it half-works.
**`mounts`** declares every prefix you will register, per tier. **The loader
compares it with what you actually register and rejects a mismatch in both
directions.** A prefix you declared and never registered fails just as loudly as a
route you registered without declaring. That is the point: the file is a statement
of your URL surface that cannot rot, because it is checked against reality at
every boot.
**Choosing prefixes is the part to slow down on.** They share one namespace with
core's own, so `/status` is not available to you — and the loader's collision
probe cannot see all of core's, because several of core's endpoints are mounted at
the tier root rather than under a prefix of their own. `template/server/index.js`
carries the current list of what core answers on the public tier in a comment
beside the registration. Read it before you choose, and choose a noun from your
own domain rather than a generic one.
`capabilities` is the opposite kind of field: opaque strings core never
interprets, published by `GET /api/v1/public/modules` while you are `started`, so
that a client — the SPA, the Android app — can feature-detect you. Two modules may
declare the same one. A client must treat an unknown capability as absent, and must
never infer a URL from one.
## The server entry point
One exported function, called once: `register(ctx, api)`. `ctx` is what core hands
you; `api` is what you hand back. Read `template/server/index.js` — it is short,
and every comment in it is load-bearing.
### Why `ctx` is handed over rather than imported
Your module lives at `<website>/modules/<id>/`, outside core's `server/`. Node's
resolver walks *up* from a file looking for `node_modules`, so it never reaches
core's — and `require('express')` from inside a module simply fails.
That is the mechanical reason, and it is the shallow one. The real reason is that
there is exactly one of certain things in the process and core owns them: one
express, so there is one `Router` prototype; one database pool; one logger; one
session reader. A second express resolved from your own dependencies would work
for about a week and then produce a routing bug nobody can reproduce.
So the rule generalises past the two obvious cases: **anything shared between core
and a module is owned by core and handed over, never resolved by the module.** On
the server that is `express` and `express-validator`; on the client it is React,
`react-dom`, `react-router-dom` and the JSX runtime. Both halves of the system
have one mechanism for it, and it is the same rule twice.
[§2.3][api] lists every member of `ctx`. It is a curated list, not core's
internals: `ctx.auth` is one function rather than core's whole auth facade,
because minting sessions is core's job and a module that needs an identity needs
to *read* one. `ctx.settings` is three functions rather than a settings model with
two dozen. Expect the narrowing, and expect to occasionally want something that is
not there — that is a conversation about a minor version bump, not a reason to
reach around it.
### The lazy-accessor pattern, and the require order it forces
`ctx` exists only from the moment `register()` is called. But the code underneath
— models, controllers, routers — is ordinary Node that requires its dependencies
at file scope, and *that* runs before `register()` does.
`template/server/core.js` is what makes both true at once: every member is an
accessor that resolves `ctx` **when it is called**, so a model can write
`const { query } = require('../../core')` at the top of the file, exactly as
ordinary code does.
Two consequences, and both have cost this project time:
**Require order is load-bearing.** A router writes `const express = core.express`
at *its* file scope, and that runs the moment the router is required. So
`core.init(ctx)` has to happen before the first `require` of anything under
`router/`. This is why `template/server/index.js` requires its routers *inside*
the register function instead of at the top of the file. Hoist them and the module
breaks with an error about a missing `ctx`, thrown from a file that never mentions
one.
**Never destructure a getter at init time.** Core is free to hand over an accessor
rather than a value — `ctx.site.baseUrl` is one — and a value captured once at
startup is a value that cannot change afterwards.
`template/server/core.js` is also deliberately a *narrowing*: it re-exports only
what the module actually uses. Copy that discipline. It makes the file an honest
statement of your dependencies, and it makes a test double for it — see
`template/server/test/_fakes.js` — a complete one rather than a guess.
## What you register
Twelve calls — ten registrations and the two lifecycle hooks — all synchronous,
all listed in [§2.4][api]. What is worth knowing is not their signatures but the
model behind them.
**Every call stages; nothing is committed until your whole module is known good.**
The shape of a claim is checked at the call, so a malformed one throws with your
own stack trace. Whether a *name* is taken can only be answered once your batch is
complete, and is checked when the loader commits it. So a module that registers two
notification streams and then throws leaves nothing behind. That matters more than
it sounds: a half-registered catalog is a stream a user can subscribe to and
nothing will ever publish to, which is worse than a missing one because it looks
like it works.
### Routes
`api.registerRoutes({ public, admin, player })` — one router per prefix per tier.
**The tier gate is already applied.** A router registered under `admin` sits
behind core's own `noindex, isLoggedIn, requireRole(...)`; under `player`, behind
`noindex, requireAuth`; under `public`, behind nothing, by design. You add
per-route gates on top of that and you never re-implement the tier gate. A module
cannot supply its own auth wrapper, and that restriction is one of the few places
the boundary is genuinely load-bearing rather than organisational: the server's
route table and the client's sidebar have to agree about who may see what, and
they only do if one thing decides.
Your router is mounted *inside* the tier router, so it structurally cannot reach
above its own prefix. This is not enforcement by review; there is no path
expressible from inside your router that escapes it.
### Extension slots
Sometimes what you have to add is not a page of your own but a section of core's.
An operator looking at a user in the admin panel wants that user's characters
right there, not on a separate screen.
`api.registerExtension(slot, router)` mounts your routes under a core resource,
and its client twin renders your component inside a core page. Core declares the
slot, you fill it, and one module per slot.
The naming rule is worth internalising, because it is what keeps a game-agnostic
core game-agnostic: **a slot is named for a PLACE, never for a meaning.**
`site.footer.status` is "the status-ish spot in the footer" — not a declaration
that core knows what a game server's status is. Core supplies the position and the
styling; the module owns the label, the target, the data, and whether it renders
anything at all. The moment core types a slot by its content, it has re-acquired
the semantics the module system exists to remove.
### Slots go the other way too
The direction above assumes core owns the page. Since `MODULE_API_VERSION` 1.6.0
there is the mirror of it, and **you will need it the moment your game has
anything like a guild**: a module declares a place on its own page and core fills
it.
```jsx
// client/src/entry.jsx — WHERE, in your words, and WHICH of core's contributions
registry.declareModuleSlot(ID, 'examplegame.clan.detail', { core: 'team.activity' })
// client/src/routes/public/Clan.jsx — from the UI kit
<Slot name="examplegame.clan.detail" externalId={externalId} moduleId="examplegame" />
```
**Why it has to invert.** A Team is a core entity — core owns the tables, the
membership sync, the access rules, the forum, the activity feed. What core does
not own is the *word*. A UO shard says guild, yours will say clan or company or
crew, and a core-rendered `/teams` page would publish a noun core invented, beside
your own page for the same thing. So the page is yours, and the parts core cannot
hand over are contributed into it. What core cannot hand over is the test for
whether something belongs in a slot: the activity feed's public/members split can
only be resolved by whatever owns membership, and that is core. You could render a
feed; you could not decide who sees which half of it.
Four rules, and the first two are the ones the shape depends on:
- **Your slot name is namespaced under your module id**, enforced rather than
conventional. It is what keeps two modules from claiming one name, and it makes
the owner readable where the slot is rendered.
- **Core names a CONTRIBUTION, never your slot.** `team.activity`, `team.forum`
and `team.notify` are core's three; the place they land in is yours to name and
yours to position. This is the half a second game depends on, and the first cut
of 1.6.0 had it the other way round — core filled three literal slot names
belonging to the first module, so everyone else's page came up empty with
nothing logged. This kit is what found that.
- **One slot per PLACE, not one per page.** A slot holds one component, so three
contributions want three declarations — and then you decide where each sits. The
template puts the notification control above its roster because muting is an
action *on* the page, and the feed and forum below it because they are content
*in* it. That decision is the reason to declare three.
- **Asking for a contribution core does not offer throws**, which is unusual here
— the client registry otherwise fails open. Core's catalogue is fixed at build
time and your `coreApi` range has already been checked, so an unknown one is
always a typo or a version skew, and the failure it would otherwise produce is a
page that renders empty forever.
`{ core }` is optional. A slot that asks for nothing stays empty, which is what
you want for a place you intend to fill yourself — and **first fill wins**, so a
module that fills its own declared slot keeps it and core's contribution is
skipped. The page is yours.
An empty slot renders nothing and is never an error: a core with no Teams, a
deployment with the forum switched off, a viewer with no membership. Design the
page to read correctly with every slot empty, because on some deployment it will
be.
### Notification streams, announce legs, post hooks
Three registries for three genuinely different things, and the distinctions are
easy to get wrong:
- **A notification stream** is a subscribable channel. You register the catalog
entry — id, label, whether it is personal, whether it needs a linked game
account — and core uses it for the subscribe endpoint and its gates. You publish
to it yourself with `ctx.push.publish`. Core never maps your events to your
streams; you have already resolved the id, and it follows that the safety rule
about which of your events may reach a *public* stream lives in your module too
— which is right, because the event kinds, the stream list and the filter are
then one file that moves together.
- **An announce leg** is one-shot delivery with retry. Core's CMS publishes a post,
every registered leg tries to deliver it somewhere, and your `classify` maps your
own result to `done` / `retry` / `terminal`. A leg that throws is caught,
classified as a retry, and never blocks another leg.
- **A post hook** maintains idempotent state, runs on delete as well as save, and
refreshes silently on an edit.
The last two fire on the same transition and are deliberately not one call. A leg
that must not be retried and a `classify` that means nothing would be the cost of
merging them.
Every hook is awaited and none may throw past core: a subscriber's failure costs
neither another subscriber nor the save itself. A hiccup in your sidecar breaking
somebody's blog post edit would be a worse bug than a stale mirror.
### Telling core something happened
Three registrations and one call, and together they are the seam where a module
is most tempted to reach past the boundary. The rule that keeps them safe is one
sentence: **you declare what CAN happen; core decides who is told.**
A **trigger** is not a notification stream, and the two are easy to confuse
because both are catalogs of things that happen in your game. A stream is a
subscribe toggle, and you publish to it yourself. A trigger is a **payload
contract**: it names the variables an event carries and how wide an audience it
may ever be given, an operator writes rules against it, and *core* does the
sending. Their ids share one namespace, so declaring both for the same id is
legal — that is one event with a toggle and a contract — while taking an id
another module owns is not.
Two fields on a trigger are worth more than their size.
**`ceiling` is required and has no default, and the values are ordered by
containment rather than by size.** It is the widest audience a rule on this
trigger may ever be given. There is no safe value to guess: `owner` silently
breaks a broadcast, `authenticated` silently widens something meant for staff.
And the ladder reading of the seven values is the trap — a `staff` ceiling does
**not** permit `owner`, because "one person" for a cheat-detection event is *the
player it was detected on*. Fewer people is not less exposure.
**`subjectKey` must name one of your declared variables**, because it is what the
cooldown is keyed on — "once per house", not "once per user". Core checks it at
registration and refuses the module, so this is one you meet at your first boot
rather than in production. The check is there because the failure it prevents is
the silent kind: a subjectKey naming nothing keys every subject on `undefined`,
which looks exactly like the feature working right up until two houses share it.
Every variable needs an `example`, and it is not decoration: it is what lets an
operator preview and test-send a body without waiting for a real game event,
which is the reason template systems ship untested. The type set is closed and
has no `object` or `array` — a message that has to walk a structure has outgrown
interpolation.
An **audience** is a named set of *people* you can resolve over your own data,
for an operator to point a rule at. "This clan's members" is one. "Everyone who
opened the last mail" is not, and nothing here builds it.
**Your resolver returns user ids and nothing else.** It is not handed a template,
a channel or an address, and it cannot enumerate them; core maps ids to addresses
on its own side, after preferences, suppression and the verification gate. That
narrowness is deliberate — a module still cannot send mail, and this is the
obvious place a back door would go. Two consequences follow from it:
- **A resolver that fails resolves to NOBODY**, never to everybody and never to
its last good answer. Core enforces that, and your resolver should choose it
too, so the log can say which clan.
- **Its params are CONSTANT.** An operator fills them in when they save the rule.
There is no way to say "the clan this event was about" — if a rule needs that,
the *event* carries its own recipients instead. This is the constraint most
worth knowing before you design around it rather than after.
The third registration ships the **content**: the bodies your messages use and
the rules that decide when one is sent. Both arrive **switched off**, and
`enabled` is not a parameter. An operator turns a module's mail on; installing a
module never does.
The two halves have different lifetimes, and the asymmetry is the contract.
**Templates re-ensure on every boot** under a seed version, so a better default
reaches deployments that never edited it while one an operator *has* edited is
left alone. **Rule groups are offered once, per named group key**, because
re-offering would resurrect a rule somebody deleted and reset one they enabled.
The consequence is easy to trip over: a rule appended to an existing group
reaches **fresh installs only**. That is the guarantee rather than a limitation
to route around, and a rule that must reach existing deployments takes a new
group key. You name the groups, so the choice is yours to make knowingly.
Core's generic bodies are a first-class answer rather than a fallback. Point a
channel at `notify.event` or `inapp.event` and author nothing; ship a body of your
own when the message has something to say that a structural projection of the
payload cannot.
**One thing in a seeded body is not checked when you register it.** The call
asserts that `blocks` is a non-empty array and stops there; the body itself is
validated by the block registry, which runs in the editor and in the renderer. So
a malformed block registers cleanly, seeds cleanly, and first shows itself when an
operator opens the body or a rule fires. Build one, open it in Admin → Engagement →
Templates once, and you have checked the half that boot cannot.
Finally the call. `ctx.events.emit(triggerId, envelope)` fires one of your own
triggers — core binds the owner from the calling module and never reads it from
the arguments, so there is no shape of this call that fires somebody else's
event. It returns nothing and, in production, never throws: there is nothing a
module could correctly do about a delivery failure from inside a game-event
handler, so there is nothing to await. **Outside production it does throw**, at
your call site — a payload that does not match the contract you declared is a bug
rather than a condition, and the throw is how you meet it in your own tests
instead of in an operator's log six weeks later.
Beside it is the one call that skips the rules entirely.
`ctx.inbox.push(userId, item)` writes a single item into a single person's on-site
inbox. Reach for it when there is nothing for an operator to decide — a job that
person started has finished — and for anything else use a trigger, so the message
can be turned off, re-targeted, or sent by mail as well without a code change. The
posture is `emit`'s: the owner is bound from the calling module, it returns
nothing, and it will not tell you that the user has that channel switched off,
because a module that could see that could enumerate people's preferences one
write at a time.
**Emit on the transition, not on the poll.** The template's `refresh()` runs every
thirty seconds and emits only when the world's online state actually changed.
Core's cooldown and hourly cap would both hold if it did not — but leaning on
them means emitting "the world is still up" and calling it news, and the operator
who tightens the cooldown to stop it has hidden your bug rather than fixed it.
Two smaller traps sit inside the same function and are worth reading in
`template/server/boot.js`: the previous state has to be read *before* the write,
or every poll looks like no change at all, and the very first boot has no previous
state, which is not a change either.
### Becoming the source of Teams
`api.registerTeamProvider({ getTeams, getTeamMembers, getTeamLeaders })` — and
this one is not like the others.
**Every registration up to here hands core something to hold.** A router to
mount, a nav row to draw, a hook to call when a post is saved. This hands core
something it will *pick up and call*, from its own reconciler, and — for the
optional fourth method — on a request path with a visitor waiting. It is the
first place in this contract where **core calls you and waits**, and every rule
below falls out of that one fact.
The three required methods answer the three questions core has about the Teams
you are authoritative for: what Teams exist, who is in one, and which of those
lead. `template/server/model/clans/clanProvider.model.js` is a working one,
including the guard clauses; the shape is:
```js
getTeams() // () => { ok, complete?, teams: [{ externalId, name, abbr?, meta? }] }
getTeamMembers(externalId) // => { ok, complete?, members: [{ memberKey, displayName?, rankLabel?,
// leader?, online?, userId? }] }
getTeamLeaders(externalId) // => { ok, leaders: [memberKey] }
// the module knows it cannot answer — sidecar down, cache cold, boot unfinished
{ ok: false, reason: 'sidecar unreachable' }
```
**The envelope is the contract, and it is not decoration.** A rejected promise, a
synchronous throw, a timeout past core's ten-second budget, a non-object, a
missing `ok`, a malformed row — core reads every one of them as `{ ok: false }`.
There is no shape a failure can take that core reads as "zero Teams". That is the
whole argument for it: a bare array has exactly one such shape, `[]`, and it is
the one you return while your sidecar is still connecting.
**So refusing is normal.** `{ ok: false }` is an ordinary answer, not an error you
failed to handle. Core keeps the projection it has, records your reason and shows
it to an operator. A refusal costs staleness and nothing else.
**The mistake to not make** is answering `{ ok: true, teams: [] }` because your
game is unreachable. It reads as an authoritative "this deployment has no Teams",
and core acts on authoritative answers — it archives Teams that have stopped
existing and departs members who have left. A cold start would empty every roster
on the site, and your module would have done it by being helpful. The template's
provider therefore refuses whenever its data might be stale, *even though the rows
it holds are perfectly readable*: core cannot tell a snapshot five minutes old
from one five days old, and it makes destructive decisions from a complete answer.
Same reasoning one level down — an empty roster is refused unless the game says
the Team is empty, because the Team and its roster arrive on separate frames in
any real ingest and there is a window where you know one and not the other.
**`projectRoster(externalId, members, viewer)` is optional and fails CLOSED**, and
that asymmetry is the part worth carrying away. It answers *who may look at this
roster*, on the request path, because the audience model is yours — core does not
know what your rungs are called and cannot invent one. For the other three, an
unanswered call must change nothing. For this one, "keep what you have" means
serving the roster unprojected to whoever asked, which is a leak. So core
distinguishes two refusals and you get the right one for free:
- **no provider, or no `projectRoster`** — nothing is being withheld, so core
serves the roster whole at its own public shape. That is what makes the method
genuinely optional.
- **a `projectRoster` that refused, threw, timed out or answered malformed** — core
serves an empty roster and says so. You claimed an opinion and then did not give
it.
Two smaller things the template gets right and are easy to get wrong: it hands
back the member keys **core** supplied (core's rows, core's `member_key` spelling)
rather than its own, and it treats an anonymous viewer — core hands over `null` —
as an *answer* rather than as a lookup that failed. The second one refuses on
every anonymous visit, which on a public deployment is most of your traffic.
**One provider per deployment.** Unlike every other registry this holds a single
value: two modules answering "what Teams exist" would produce two disjoint sets
under one table with no rule for merging them.
**`pageUrlTemplate` is data, not a method** — `'/examplegame/clans/{externalId}'`
— and it is the fifth member. Teams have no core page, so core cannot work out
where yours is, and a notification email about a forum reply that cannot link to
the thread is most of the way to useless. A relative path only; core substitutes
`{externalId}` and `{slug}` and does nothing else with it. Data rather than a
callback deliberately: a function here would put a module hook on the mail path,
one more thing that can hang, to produce a string that never varies.
**What core never gets is your tables.** It asks the questions; you own the
storage, the ingest and the game↔site account mapping (`userId` on a member is
resolved by you, because a core that resolved it would be core reading a module's
table by name). The traffic in the other direction is `ctx.teams.*`, and it is
narrow on purpose.
### The lifecycle hooks
`api.onBoot(fn)` runs after core's schema, after your schema fragment, and
**before the HTTP listener binds**. It is the first moment a database exists, so
it is where everything that needs one goes: warming a cache, backfilling,
connecting to your sidecar.
**`onBoot` has no timeout, deliberately.** A slow boot delays the listener, and
that is the guarantee rather than a problem to be timed out — a module that must
not serve traffic before it has warmed up gets exactly that. If your `onBoot`
throws, you are `startup_failed`: your routes stay mounted and answer `503`, and
the site comes up without you.
`api.onShutdown(fn)` runs while core's pool, push dispatcher and event fan-out are
all still open, because flushing through them is the only thing it is for. It has
a five-second budget and is abandoned past it — the process is exiting anyway, and
the alternative is a host where stopping the service waits for a kill.
A module whose `onBoot` threw gets **no** `onShutdown`. It is part-way through a
warm-up it never finished, and handing it a half-built world to tear down is worse
than not closing cleanly. A module with no hooks at all still reaches `started`:
having nothing to warm up is not the same as never having started.
## The schema fragment
`template/server/db/schema.sql` is your tables. It is replayed **in full, at every
boot**, statement by statement, right after core's own schema.
**There is no migration runner anywhere in this project, and that is a decision
rather than an omission.** Core's own schema is one idempotent file replayed the
same way. What you get in exchange is that a module's schema is a single readable
statement of what its tables are, with no ordering history to reconstruct and no
migration table to get out of step with the tables themselves.
What it costs you is that **changing a table is an `ALTER`, never an edit to its
`CREATE`.** `CREATE TABLE IF NOT EXISTS` no-ops against an existing table, so an
edited column definition lands on fresh installs only — and your development
database is usually the fresh one, which is what makes this bite six months later
on somebody else's instance. Add the column with
`ALTER TABLE … ADD COLUMN IF NOT EXISTS`, leave the `CREATE` alone, and both paths
converge.
Two rules the loader enforces before your module is mounted at all:
**Leading verbs are an allowlist: `CREATE`, `ALTER`, `INSERT`, `UPDATE`.** Not a
`DROP` denylist — because the file is replayed at every boot, `TRUNCATE` and
`DELETE` would empty a table at every restart and `RENAME` would fail at the
second one. A denylist only ever bans what somebody thought of.
**Table names are namespaced `<id>_` and collision-checked** against core's tables
and every other module's. A `CREATE TABLE` missing `IF NOT EXISTS` is rejected on
the same grounds as the rest: it succeeds exactly once and fails every boot after,
which presents to an operator as a module that broke on restart.
Both are checked by *reading the file*, before anything mounts, and that split is
the design: everything knowable without a database costs you the mount, so a
rule-breaking fragment never half-applies; what only a database can answer — an
unknown column type, a bad foreign key — happens later and answers `503`.
`purge.sql` is the destructive counterpart, and it is required whenever you ship a
schema. It runs **only** when an operator explicitly purges you, never on
uninstall. A module that can create tables and cannot drop them leaves an operator
with orphaned data and no supported way to remove it.
One more thing about a file that replays: **a guard and the statement it guards
must live in the same file.** If you write a one-shot data fix conditioned on a
marker, put both the marker and the fix in your own fragment. Core's schema
replays in full before any module's, so a marker core writes has already been
written by the time your guard reads it — a real defect this project shipped and
did not notice, because it is latent until the day someone installs on an older
version.
## The client half
Your client half is a **prebuilt ES module**. Core serves it from your module's
directory as a same-origin script and injects a `<script type="module" src>` for
it before `</body>`. There is no bundling step on the operator's machine, ever.
### One React, and core owns it
`window.__rg` is core's published set of shared dependencies plus the registry,
the UI kit and a request primitive ([§3.2][api]). Your build does not bundle React
— it aliases every shared specifier to a two-line shim that re-exports from that
global.
The failure this prevents is specific and nasty: a second React in the page is a
second hook dispatcher, so your component throws about an invalid hook call
somewhere unrelated to the mistake, in a page that otherwise loads fine.
`template/client/vite.config.js` is the whole mechanism, and its comments are the
most valuable prose in the template. Three things there were wrong first and are
now contract:
- **The aliases use the array form with anchored regexes.** Vite's object form does
prefix matching, so a `react` key also silently rewrites `react/jsx-runtime` — to
the wrong shim.
- **The aliases replace `external`; they do not accompany it.** Rollup asks
`external` *before* Vite's alias resolver runs, so a specifier in both is never
aliased and the chunk ships bare `import 'react'` specifiers. A browser cannot
resolve those without an import map, and core's `script-src 'self'` forbids the
inline script an import map has to be. The first real module shipped exactly that
chunk, from a clean green build.
- **The build guard hooks `transform`, not `load`**, and its forbidden-package list
is stated rather than derived from the alias list. `load` is first-wins, so
written against it the guard sat in the build doing nothing. Deriving the list
means deleting an alias also deletes the guard against what that alias prevented
— precisely when it is needed.
`template/client/scripts/checkExternals.js` asks the **built chunk** whether any
bare specifier survived. That question cannot be asked of source:
`import { useState } from 'react'` is correct in every file, and which React it
becomes is decided by the build config. Run it in your CI.
### Registration happens at evaluation time
`template/client/src/entry.jsx` registers your routes and nav rows with plain
top-level calls. There is no subscription and no late registration: module chunks
are deferred scripts that execute after core's bundle and before core's first
render, so everything you register is present in that first render.
**So every page is a static import, and lazy-loading your routes is the one thing
this seam cannot have.** 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 — indistinguishable from a module that failed to
load.
That timing is also where this project's most instructive client-side bug lived.
Core's own render used to wait on `document.readyState === 'loading'`; but a
deferred script runs *after* the document is parsed, so `readyState` is already
`'interactive'`, and core mounted immediately — before any module chunk had
evaluated. Every unit test passed. It was found by loading a real chunk in a real
browser, which is the only place it was visible.
### Nav, and what a registered row becomes
`registry.registerNav` interleaves your rows into **core's** navigation groups,
and from that moment your row is an ordinary row: an operator can reorder,
relabel or hide it from the nav editor exactly as they can core's. That works
because the interleave happens *before* the admin override merge — the override
layer is keyed by a row's `to`, and it drops keys its base does not declare, so a
row appended afterwards would be unorderable, unrelabellable and unhideable.
Three details worth knowing before you need them:
- **A row with no `order` appends after core's rows** rather than defaulting to
zero. "I didn't ask for a position" must not mean "put me first".
- **An unknown `group` name appends a new group** rather than dropping your row.
- **`icon` has no core fallback.** Public header rows carry no icons, so a public
row needs none; an admin or player row without one is the only glyph-less row in
its sidebar, which reads as breakage. Match the nav you land in rather than
shipping one glyph for everywhere.
`registerFeatureProvider` is how a row can be conditional: core keeps a generic
flag context and you supply the hook that fills your namespace. **The namespace
comes from the registration, not from parsing the string**, so a typo'd namespace
is not a thing that can exist.
**Everything in this layer fails open.** No provider, an answer still in flight, a
malformed row — all of them show the link. This is presentation and the server is
the gate: a UI mistake that hides a page from someone entitled to it is worse in
every case than one that shows a link which then answers `403`.
### The UI kit
Core publishes a small set of components and hooks on `window.__rg.ui`
([§3.4][api] is the list): the public layout, a page header, the loading, error
and empty states, the async hook every data page uses, read-only access to the
session and site settings, and `Slot`. Enough to build a page that looks like the
site it is installed in, and nothing else.
`Slot` is the odd one — not a widget but the thing that renders a place you
declared for core, from ["Slots go the other way too"](#slots-go-the-other-way-too)
above. It is in the kit rather than left to you for the reason the kit exists at
all: reimplementing it would mean a second error boundary with different
behaviour, and what this one contains is *core's* content failing inside *your*
page.
**`PublicLayout` needs a `shell`, and this is the one that will catch you.** The
layout is the *chrome* — header, footer, the flex column they sit in. The `shell`
prop is the *body*: the centred max-width column, the vertical padding, and the
element whose `flex: 1` is the only thing holding the footer at the bottom of the
viewport.
```jsx
<PublicLayout shell="narrow"> // 'narrow' · 'mid' · 'wide'
```
Omit it and your content starts hard against the left edge of the window with no
padding, and the footer climbs up underneath it. It reads as a stylesheet bug in
your module and it is not one — core's own pages write that wrapper by hand, and
before `MODULE_API_VERSION` 1.5.0 a module had no way to. **Name a width, never a
class:** the class names are core's stylesheet's and it is free to rename them,
which is exactly why they are not in the contract and this prop is.
That paragraph exists because the kit's acceptance run
([`kit-acceptance.md`][acceptance]) built a module by following this chapter to the
letter, and its page rendered outside the site. Everything else it wrote was right.
**And check a component's prop names against [§3.4][api] rather than guessing
them.** `PageHeader` takes `eyebrow`, `title`, `lead` and `center` — a page that
passes `subtitle` renders its heading and nothing under it, because an unknown
prop on a React component is silently dropped. Nothing warns, in the console or
anywhere else; the page simply looks emptier than every core page around it. This
template shipped exactly that mistake until a run of it against a real core was
looked at, which is the only way that class of thing is ever found.
**It is curated and closed, not a re-export of core's component library.** Adding
to it is a minor version bump, and so is adding an optional prop to a member;
changing an existing prop is a major one.
That is a real constraint on core, and it is the price of the boundary being worth
anything.
So: when you want something it does not have, bundle it. Tables, chips, tabs, editors — those
are yours, and your chunk carries them. Reaching into core's tree for a component
is the one thing that is never available, and `template/server/scripts/checkImports.js`
exists to make sure a moment of weakness fails the build instead of shipping.
One thing that surprises everyone once: **core's public pages render the public
layout themselves** — it is a component, not a route wrapper. A public page of
yours that does not use `ui.PublicLayout` renders bare, with no site chrome. That
is the contract working as intended, not a bug to hunt.
## The OpenAPI fragment
Every module that registers routes ships `swagger-fragment.json` in its bundle
root, and core merges the fragments of started modules into its own API document.
The filename is fixed rather than declared, like `module.json` itself.
**Generate it from your own registrations** — `template/server/scripts/swaggerFragment.js`
is a working generator. It runs your `register()` against a recording `api` and
resolves each router back to its source file, so a mount prefix exists in exactly
one place rather than being retyped into a generator that then drifts.
Two rules and one trap:
**Namespace what you define; reference core's shared schemas by core's name.** A
schema you invented gets your prefix. `Error` and `ValidationError` are core's:
reference them and do not redefine them. They resolve in the merged document,
which is the only place both halves exist — and shipping your own copy is a
collision core drops, arriving at the same result the expensive way.
**Commit the generated file and check it is current in CI.** Core merges it
verbatim, so a stale fragment documents a URL surface you do not serve, and
nothing at runtime will ever say so.
The trap: **swagger-autogen reports a broken annotation and then prints
`Success`.** It logs a syntax error, drops that annotation, and exits zero. The
template's generator captures those diagnostics and fails on them — keep that.
The usual cause is an object literal one brace short.
**A quote character is worse, because it does not log anything.** These
annotations are evaluated as JavaScript literals, so a `'` or a `"` inside a
single-quoted description ends the string early — and for a `"` in the middle of
a sentence the result is not an error at all. The value is silently **truncated**
at that character:
```js
// #swagger.summary = 'A "quoted" world status'
// → "summary": "A \"" and swagger-autogen still prints Success
```
Nothing throws, so the generator's error capture has nothing to capture. The only
signal is `check:swagger` calling the fragment stale, with a message that blames
your routes. **If that check fires and your routes did not change, look for a
quote in an annotation first.** Backticks are safe — Markdown spans survive
verbatim. And an escaped apostrophe (`\'`) is a third case, visible only in a
rendered page: the annotation is never evaluated as JavaScript by the reader, so
Swagger UI shows the backslash. Use a typographic `’` throughout.
## Packaging and release
`template/.gitea/workflows/release.yml` (and its GitHub twin) is a working release
pipeline. Copy it to the root of your module's repository — a workflow file is
only read from a repository root, which is why it does nothing where it sits
inside the kit.
**A release is not source.** It is the directory core's loader expects to find at
`modules/<id>/`, already assembled: the prebuilt chunk, your runtime dependencies
installed, the schema fragment, the OpenAPI fragment. Core downloads the tarball,
verifies it against the `sha256` in the install manifest, and unpacks it. Nothing
runs `npm` on the way.
**The version is computed from your commit subjects, and `module.json`'s is a
floor.** Every push to `main` carrying a `feat:`, `fix:` or `perf:` publishes a
bundle — `feat!:` and `BREAKING CHANGE` make it a major, `feat:` a minor, the
rest a patch — and a `main` that gained none of those cuts no release. The number
that ships is the **tag**, which the workflow writes into the `module.json` inside
the bundle.
The alternative is tempting and it is what this project's own reference module
did first: let `module.json`'s version decide, and release whenever a push leaves
it at a version with no release yet. You already have that number, it is what core
records and what the admin panel shows, and two sources for one number is how they
drift. It was abandoned on 2026-08-19 for a reason worth knowing before you copy
either shape — **its cost is paid on every release, and the drift it prevents is
something review catches anyway.** A week of merged work produced no bundle at
all, because none of it happened to touch that line, and shipping it meant first
merging a pull request whose entire content was a number.
So the declaration is kept, demoted to a floor: name a version in `module.json`
above the newest tag and *that* is what releases. It is still how you say "this
one is a minor" when a `coreApi` bump forces the question. And for a change with
nothing releasable behind it — a widened `coreApi`, a new mount, a new capability
— run the workflow by hand: leave `version` blank to bump the newest tag by
`bump`, or type an exact version.
The workflow tags and publishes and never writes to a branch, so a protected
`main` needs no exception.
The install manifest is the JSON your workflow publishes beside the tarball. Its
URL is what an operator pastes into Admin → Modules, and the host it lives on has
to be on that core's allowlist — an operator-controlled setting, so tell your users
where you publish.
## Boundaries
[§2.7][api] is the list. Each item has a failure behind it:
| Rule | What it prevents |
| --- | --- |
| No `require` outside your own directory (bar built-ins and your own dependencies) | Two copies of a thing there must be one of; and a module that survives a core refactor only by luck. |
| Do not mutate `ctx`, `req.user`, or anything core handed you | A module changing another module's world, invisibly. |
| No app-level middleware, no Express error handler | One module deciding how every other module's errors are rendered. |
| Do not read `process.env` for core configuration | Configuration with two sources and no panel. Your own config is a settings key or your own table. |
| No `process.exit`, no signal handlers, no listeners | A module taking the site down, or racing core's shutdown. |
| Write only inside your module root and the upload directory | A module that cannot be uninstalled cleanly. |
| Never read or write a core table — including the Team tables you populate | A module racing core's own reconciler for rows core owns. You answer questions about Teams; core stores them. |
| **Never open a connection to a game server from the website process** | The whole of [chapter 3](03-sidecar.md). |
That last one is newer than the others and is the reason this kit is three
chapters and not one. It is also the only rule in the list with **no CI behind
it** — an outbound socket is not statically detectable the way an internal
`require` is — so it is enforced in review and by understanding it, which is what
the next chapter is for.
[api]: https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/website/MODULE_API.md
[acceptance]: https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/modules/kit-acceptance.md

203
book/03-sidecar.md Normal file
View File

@@ -0,0 +1,203 @@
# 3. The sidecar
Your module may not open a connection to a game server. Not a game socket, not an
RCON channel, not a query port, not an engine's admin API. It talks to a
**sidecar**, and the sidecar talks to the game.
That is a rule in the contract ([`MODULE_API.md`][api] §2.7, as of
`MODULE_API_VERSION` 1.4.0) rather than advice this kit is offering. It is also
the rule most likely to feel like ceremony when your game already exposes a
perfectly good remote-control protocol and your module is fifty lines from
working. This chapter is why it is not.
**It is the one rule in that list with no CI behind it.** An outbound socket is
not statically detectable the way an internal `require` is. So it is enforced by
review, and by you having read this.
---
## What a sidecar is
A small, separate service that owns the connection to your game, keeps a durable
copy of what the game said, and exposes an HTTP + WebSocket API that the website's
backend reads.
```
your game server ──dials out──▶ your sidecar ──HTTP + WS──▶ website core
│ (your module)
▼
its own store
```
Three properties, and each is doing real work.
## 1. The game dials out; the sidecar listens
The sidecar binds the listener. The game connects **to it**, and the game opens no
listening port at all.
This is the inversion people find surprising and it is the load-bearing one. The
website is the internet-facing process; your game is not, and must not become
reachable because a web app knows how to reach it. A module holding the connection
makes the public web app the thing the game trusts, and puts the game's address
inside the same process as every request from the internet.
In `uo-link`, that listener is `sidecar/src/shard.rs` — `serve` binds a loopback
address and accepts shard connections forever, handling one at a time and looping
back to accept the next. The game plugin does the dialling, with its own backoff.
Loopback, in that deployment, because the sidecar runs on the game host: the only
socket the game speaks over never leaves the machine.
Only the website's backend talks to the sidecar, and it authenticates. `uo-link`'s
`web.rs` requires a token on every request — accepted as a bearer header, an API-key
header, or a query parameter, that last one only because browser WebSocket clients
cannot set handshake headers — and compares it in constant time. Auth is always on;
there is no unauthenticated mode to accidentally deploy.
## 2. Persist before you forward
This is the property that makes a sidecar worth having even when your game is
already remote-controllable, and the one a message-passing diagram never conveys.
**The sidecar owns the durable copy.** It writes what the game said into its own
store, and answers reads from that store — not by round-tripping the game.
`uo-link` does this in `sidecar/src/store.rs`: SQLite, holding event history, the
latest snapshot of every board the site renders, the economy series and the
published ruleset. `insert_event` is called for every live event as it is
broadcast; the `upsert_*` functions keep one current row per board; the REST read
paths query that store.
What it buys, concretely:
- **A website that is down, restarting or mid-deploy loses nothing.** Events that
arrive while nothing is listening are still recorded. Without a store they are
simply gone, and your first deploy of the week is a hole in your data.
- **A page renders the last thing the game said rather than going blank.** A rules
page that empties itself because the game restarted is worse than a stale one.
- **The live feed is allowed to be lossy.** `uo-link`'s WebSocket fan-out drops
frames for a consumer that has fallen behind and logs that it did — deliberately,
because durability is the store's job and not the socket's. A feed that instead
buffered without limit for a slow client would eventually take the sidecar down.
That last point is the reasoning to carry into your own design. Once the store is
authoritative, every other component is allowed to be best-effort, and each of them
gets simpler. Skip the store and you find yourself trying to make a socket reliable,
which is the hard version of this problem.
A module cannot do any of this from inside the website process. There is nowhere to
put what arrives while the website is not running, because the website not running
is exactly the case.
## 3. The wire is a versioned contract, not a build dependency
Your sidecar and your module ship separately, on different schedules, to hosts you
do not control. So the wire between them is a compatibility contract with a version
on it.
`uo-link` declares `PROTOCOL_VERSION` in `sidecar/src/main.rs`, stamps
`X-UOLink-Version` onto every response from `web.rs`, and **refuses a request whose
declared version does not match** rather than parsing it optimistically. A refusal
is a clear failure an operator can act on; a mis-parse is a wrong number on a page
with nobody to tell.
Two habits come with that:
- **Bump the version in the same change that changes a message shape**, on every
side that declares it. In this project a protocol bump has three declaration
sites — the sidecar, the game-side overlay's manifest, and the documented spec —
and the tooling refuses to pair components that disagree.
- **Version the *shape*, not the content.** Adding a new event kind that old
consumers ignore is not a break. Changing what a field means is, even when the
JSON still parses.
## The worked example
`uo-link` is a complete implementation of everything above, and it is small enough
to read:
| File | What it owns |
| --- | --- |
| `sidecar/src/shard.rs` | The listener the game dials into; one connection at a time, then accept the next. |
| `sidecar/src/store.rs` | SQLite: event history, per-board snapshots, the series and the ruleset. |
| `sidecar/src/web.rs` | HTTP + WebSocket for the website, the auth middleware, the version header and the lossy live fan-out. |
| `sidecar/src/rpc.rs` | Request/reply correlation, so a website read can ask the game a question and match the answer. |
| `sidecar/src/config.rs` | The config file, including a token generated on first run rather than defaulted. |
The protocol it speaks is specified in [`link/PLAN.md`][linkplan] and
[`link/INTEGRATION.md`][linkint]. Those are normative for that sidecar; your game
is not Ultima Online and your messages will not be its messages. What transfers is
the structure — a listener the game dials into, a store written before anything is
forwarded, a lossy live feed, an authenticated read API with a version on it.
## "But my game already speaks a remote-control protocol"
Then your sidecar is **thin**, not absent — and be sure the surface you are
thinking of actually carries what your module needs, because that is where this
question usually goes wrong.
A remote-control channel is built for an operator typing commands: it tells you
what you asked about, when you ask. What a website needs is what *happened* —
every kill, every join, every departure, delivered whether or not anyone was
listening at that moment. Those are different products, and a channel that
answers the first can only approximate the second by polling it, which turns
"someone left the clan at 14:02" into "the count was different at 14:03".
So the honest test is not *does my game expose a protocol* but **does it deliver
events**. If it does, your sidecar keeps that connection, its credentials and its
reconnect loop out of an Express process, keeps a store so the site is not blank
whenever the game restarts, and presents your module one versioned HTTP + WS
shape. That is what thin means: less code, the same architecture.
**Rust is the worked example, and it is not that case.** The dry run in
[`rust-dryrun.md`][dryrun] designs a module for it precisely because it shares so
little with Ultima Online — and its answer is an **Oxide plugin**: C# loaded by
the mod framework a modded Rust server already runs, hooking the game's events and
dialling out to a sidecar, exactly as the ServUO overlay does. The Rust server is a
*binary*, where ServUO is source a shard owner compiles, so the way in is a
published hook API rather than a file you edit. **The three-part shape survives
that unchanged**, which is the more useful finding: the plugin-dials-out
arrangement is not a property of having source access.
It also pairs **one sidecar to one game server**, on that server's own host,
rather than one sidecar fronting a community's several — because those servers sit
on separate machines, and a shared sidecar would be reached across a network by
plugins that are supposed to talk to it over loopback. Worth knowing before you
design yours: if your game runs as a fleet, the question "how many sidecars" is
answered by where the loopback boundary is, not by how many processes you would
rather run. Your module holding several clients is the cheaper end of that trade,
and core never learns there is more than one.
That document reached the game over RCON until 2026-08-19, and before that
concluded there should be **no sidecar at all**. It carries both corrections,
dated, rather than having been quietly rewritten — the value of a dry run is the
record of what it found, including where it was overruled.
## Building yours
There is no template for a sidecar in this kit; it is your program, in your
language, and the surface it must expose is the surface your module reads. What to
settle before writing code:
1. **Which direction does the connection go?** The game dials out. If your game
cannot — if it only accepts connections — then your sidecar is the client to the
game and the listener for the website, and the rule that stands is the one that
matters: the address of the game is known to the sidecar and to nothing else.
2. **What is durable?** Everything a page must still render when the game is down.
Write it before you forward it.
3. **What is a snapshot and what is an event?** They are different storage
problems: an event is appended and read back as history, a board is one current
row per subject that you overwrite. `uo-link`'s store holds both, and keeping
them separate is why a restart does not replay a year of events at a page.
4. **What is the version, and where is it declared?** One place, on every response,
refused on mismatch.
5. **How does the website authenticate?** A token, generated rather than defaulted,
always required.
Then chapter 4, if your game needs code inside it — which is the part where getting
it wrong takes the game down rather than the website.
[api]: https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/website/MODULE_API.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
[dryrun]: https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/modules/rust-dryrun.md

186
book/04-game-plugin.md Normal file
View File

@@ -0,0 +1,186 @@
# 4. The game-side plugin
The chapter with the least code and the highest stakes. Everything else in this
book fails by showing an operator a broken web page; this part fails by taking the
game down while people are playing it.
If your game already **delivers events** on a surface of its own, you may not need
any of this — see the end of [chapter 3](03-sidecar.md), and read the test there
before deciding, because a channel that answers questions is not the same thing.
Otherwise something has to run inside the game and feed your sidecar, and the
rules below are what keep that something from being the reason the server froze.
**That something does not have to be source you compile.** The worked example
below is an overlay built into a server whose code you have; the dry run for Rust
is an **Oxide plugin**, C# loaded by a closed server's own mod framework and
hooking published events. Every rule in this chapter applies identically to both —
they are properties of being inside a game loop, not of how you got there.
The worked example is `servuo-plugins`, the Ultima Online shard plugin, whose link
layer is one file: `overlay/Scripts/Custom/Bridge/BridgeLink.cs`. It is C# against
a specific game engine and none of that transfers. The threading contract at the
top of it does, entirely.
---
## The one rule: never block the game
A game server is a loop. Whatever thread runs the world is the thread that must
not stop, and every rule in this chapter is a restatement of that.
**Emitting an event must enqueue and return.** It formats nothing expensive, waits
on nothing, and touches no socket. In `BridgeLink.cs`, `Emit` is called from the
game's own thread, appends a line to a queue, signals a waiting writer, and
returns. A sidecar that is slow, wedged, restarting or entirely absent cannot stall
the game, because the game never touches the connection.
The failure this prevents is not hypothetical, and it is not a small one: a socket
write from the world thread against a peer that has stopped reading blocks until
the OS buffer drains. That is a frozen game server, caused by a monitoring
feature, at exactly the moment something else is already wrong.
## The queue is bounded, and it drops the oldest
An unbounded queue in front of an absent consumer is a memory leak with a delay
timer on it. So the queue has a cap, and when it is full **the oldest record is
dropped and counted**.
`Emit` bounds first and enqueues second, so the queue can sit transiently one over
the cap and never grows without limit. `BridgeLink` exposes counters — sent,
dropped, received, connects, write errors, current depth — and those counters are
what an operator debugs from later.
Dropping is correct here, and it is worth being explicit about why: **telemetry is
worth less than the game's memory.** If your sidecar has been unreachable for ten
minutes, the useful thing is the most recent state of the world, not a
ten-minute-old backlog delivered before it. Newest-wins is the honest policy, and
"stall the game rather than lose an event" is never the trade to make.
Where losing events is genuinely unacceptable, the answer is the sidecar's store
([chapter 3](03-sidecar.md)), not a bigger queue inside the game.
## One writer thread owns the socket
A dedicated thread drains the queue and owns the connection. It connects,
reconnects with backoff, and writes.
**A single writer is also what keeps event ordering intact** — with two, the order
events reach the sidecar is the order two threads happened to be scheduled in, and
you find out from a board that says a player logged out before they logged in.
The reconnect loop in `LinkLoop` backs off with a low ceiling — a few seconds,
because a loopback reconnect is cheap and a sidecar restart should cost a few
seconds of buffering rather than half a minute of blindness. Pick your ceiling from
what the connection actually costs, not from a habit borrowed from internet
clients.
One detail there is subtle enough to be worth stealing: `BridgeLink` tags each
connection attempt with an **epoch**, so a reader thread from a previous connection
cannot tear down the connection that replaced it. Joining a thread can time out;
the stale thread's cleanup then runs against whatever is current. If you write a
reconnect loop, write it so a late-arriving cleanup from a dead connection is a
no-op.
## Read the world only on the game's thread
Inbound is the mirror image. A reader thread parses lines off the socket, and then
**hands each one to the game's own thread** to act on — `BridgeLink`'s `Dispatch`
does it by scheduling a zero-delay callback on the game's timer, which is the
engine's supported way in. The reader itself never touches the world's objects.
Two rules fall out and both are absolute:
- **Every read of the world happens on the world's thread.** Game engines are
overwhelmingly single-threaded about their state, and reading a collection while
the loop mutates it is a crash or, worse, a corruption you notice a week later.
- **The writer thread only ever sees plain data.** Format your line — a string, a
buffer, whatever your wire is — on the game's thread while the objects are safe
to read, and hand the finished bytes over. Never hand the writer a live game
object to serialise.
And an error boundary at the seam: a malformed command from the sidecar must never
escape into a game code path. `BridgeLink` wraps the inbound handler and logs
anything it throws, because the alternative is an exception unwinding somewhere in
the engine's main loop.
## Reconnect, and what to send on connect
Your sidecar restarts independently of your game. It comes back with an empty
picture, and it cannot ask the game for one without an inbound path you may not
have built yet.
So **anything the sidecar needs up front is re-sent on every connect, not once at
startup.** In `servuo-plugins` that is an explicit event: the link exposes a
"connected" hook that runs on the game thread, and each feature area subscribes to
it and re-emits its current state — the hello line, the house registry, the guild
and governor boards, the market, the ruleset. A sidecar that has just started is
therefore fully populated within one connection, with no negotiation.
The general form: **for every board your website renders, have exactly one place
that can produce its current state, and call it on connect.** If you cannot name
that place for some piece of state, your sidecar will eventually be missing it and
nobody will know why.
## Events, snapshots, and the state that has neither
You will end up emitting two different kinds of thing, and confusing them is a
design mistake that shows up as a bad page.
**An event** is something that happened, at a time: a player logged in, a house
fell, a trade completed. Events are appended and read back as history.
**A snapshot** is the current state of a subject: this board's rows, this guild's
membership, the published ruleset. Snapshots overwrite; nobody wants the history of
a leaderboard's every intermediate ordering.
Send both, and be clear at the wire about which a message is — your sidecar's store
handles them differently ([chapter 3](03-sidecar.md)), and a snapshot appended as
history is a table that grows forever.
Then there is the state your engine gives you no hook for at all. Player vitals,
decay timers, money supply: nothing fires when they change. `servuo-plugins` polls
those on the game's own thread with repeating timers, in
`overlay/Scripts/Custom/Bridge/BridgeSweeps.cs`, and the comment worth copying is
that this is only acceptable **because the cost was measured**. A full pass of all
three sweeps is well under a millisecond at that shard's scale. Measure yours
before you add a timer to a game loop, and if a sweep is expensive, sample it —
never move it off the game thread.
Two practical notes from that file, both general:
- **Emit a transition, not a level.** The decay sweep keeps the last known level per
house and emits only when one changes, and it takes a silent baseline at startup
so a restart does not re-announce every house's current state as news.
- **Know when your engine suspends timers.** These do not fire during a world save,
so a sweep that would have landed mid-save simply happens a few seconds later.
That is fine for all three — but it is fine because someone checked, not by
default.
## A checklist for the plugin you are about to write
1. The emit path enqueues and returns. Nothing on the game thread touches a socket.
2. The queue is bounded and drops the oldest, and something counts the drops.
3. One writer thread owns the connection; ordering is therefore intact.
4. Reconnect with a bounded backoff; a stale connection's cleanup cannot affect a
newer one.
5. Inbound lines are marshalled onto the game thread before touching the world, and
a handler that throws cannot escape into the engine.
6. Every board's current state has exactly one producer, and all of them run on
connect.
7. Every world read is on the world's thread; the writer sees only plain data.
8. Anything polled has had its cost measured against a realistic world.
If all eight hold, the worst a broken sidecar can do to your game is nothing at
all — which is the entire point of the arrangement.
---
That is the book. The three parts are a module core loads, a sidecar that owns the
game connection and the durable copy of what it said, and a plugin that feeds the
sidecar without ever waiting on it.
If you got this far and built something, the places you got 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.
[issues]: https://gitea.whitlocktech.com/RunicGateway/Integration-kit/issues

View File

@@ -1,99 +1,40 @@
# 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.
Four chapters, in the order the work happens.
Read [the dry run][dryrun] before any of them.
Read [the dry run][dryrun] before any of them — a complete module designed on
paper for a second game, and the shortest honest picture of the whole job.
| # | 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 |
| # | Chapter | What it covers |
| --- | --- | --- |
| 1 | [Your first module in twenty minutes](01-first-module.md) | Copy the template, rename it, build it, install it, see a page. No theory. |
| 2 | [The website module](02-website-module.md) | The bulk of the work: `module.json`, `register(ctx, api)`, the schema fragment, the client chunk, packaging, and what a module must never do. |
| 3 | [The sidecar](03-sidecar.md) | Why the website never talks to a game server, what "persist before you forward" means, and what a *thin* sidecar is. |
| 4 | [The game-side plugin](04-game-plugin.md) | The least code and the highest stakes: never block the game thread. |
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.
Chapters 1 and 2 quote `template/`, which CI builds against a pinned core, so their
code is a tree that is proved rather than prose that looks like one. Chapters 3 and
4 cite `uo-link` and `servuo-plugins` by file and identifier rather than by line, on
purpose: those repositories move for their own reasons and a line number in a book
is wrong the moment they do.
## 1. Your first module in twenty minutes
## What is normative, and what is here
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.
Nothing in these chapters is. Where a chapter and one of these disagree, the
document is right and the chapter has a bug — [say so][issues]:
- 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.
| Authority | For |
| --- | --- |
| [`MODULE_API.md`][api] | Everything a module may do. |
| [`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 game↔sidecar wire protocol, as one real sidecar implements it. |
## 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.
The chapters teach: the order to do things in, the reasoning, and the mistakes that
cost this project time.
[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

View File

@@ -1,22 +1,47 @@
{
"repo": "https://gitea.whitlocktech.com/RunicGateway/website.git",
"branch": "edge",
"ref": "1b692bf624404f9e4f924c231acfbfb7e9d0861a",
"branch": "main",
"ref": "66bb3b9a3fad01112c06f32d931c9bae56d22de6",
"why": [
"The core this kit is written against, pinned to a commit rather than a branch.",
"This one is the MODULE_API_VERSION 1.4.0 bump, which is the version",
"template/module.json declares - slice 0 pinned its parent, before 1.4.0",
"existed, and the check below could not have passed against it.",
"This one is the engagement cutover, the commit MODULE_API_VERSION 1.9.0 reached",
"`main` on, and 1.9.0 is what template/module.json declares. It moved here from",
"1.6.0 (the Teams cutover) because engagement expanded the contract the book",
"teaches by three registrations and two calls: a module now declares what its",
"game can announce and never who is told.",
"",
"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).",
"(MODULE_SYSTEM.md 2.11.1 d2, 2.10). Note what that means in the other",
"direction, because it is easy to misread as a safety net: the check clones",
"THIS ref, so a core that has moved past it does not turn the repo red on its",
"own. Nothing goes red until someone moves the pin. Between cutovers the kit is",
"not wrong, it is DATED - and this file is where the date is written down.",
"",
"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.",
"The mechanism earned its keep again here. Writing chapter 2's engagement",
"section against 1.9.0 found that the seeded body a module ships is the one",
"thing registerEngagementSeeds does not validate - it checks that `blocks` is a",
"non-empty array and stops - so the template's own example body had a heading",
"level of 2 where the block registry takes 'h2', and no block ids at all. It",
"would have registered, seeded, and failed the first time an operator opened it.",
"Caught by running the template's register() through core's real registry at",
"this ref, which is what a re-read is for; both the fix and the gap are now in",
"the chapter and beside the code.",
"",
"That gap is also why this file's own instruction is not enough on its own. The",
"template job builds and tests the template against fakes and checks this",
"number; it does not load the module into core. A declaration a fake accepts",
"and core refuses would ship green, so a pin move is a run against a real core,",
"not just an edit here.",
"",
"The branch said `edge` until 2026-08-12, when the module system cut over and",
"that branch was deleted (MODULE_SYSTEM.md 2.9). Two later workstreams cut an",
"`edge` of their own and this pin skipped both: the kit is written against what",
"shipped, never against what is in flight. Nothing in CI reads the branch field",
"- it clones the repo and checks out the sha - which is why a wrong label here",
"would sit unnoticed. It is for the person deciding whether a newer core is",
"worth re-reading the book for.",
"",
"Same convention as Module-uo's ci/core-ref.json, deliberately - one file, one",
"sha, reviewable in a diff."

View File

@@ -0,0 +1,142 @@
#!/usr/bin/env node
// Every path in this repo that a chapter names in backticks must exist.
//
// The book teaches out of `template/`: it says "open
// `template/server/index.js`", "the aliases are in `template/client/vite.config.js`",
// "your tables go in `template/server/db/schema.sql`". None of that is a markdown
// link, so `checkLinks.js` never looks at it — and none of it is code, so nothing
// else does either. Rename one template file and four chapters quietly point at
// nothing, which is the exact rot this repo exists to be immune to.
//
// This is the cheap half of "is the book still true", and it is honest about
// being only the half a machine can answer. Whether a paragraph has become wrong
// about a file that still exists is a reviewer's job (MODULE_SYSTEM.md §2.10).
//
// ── What counts as a claim about this repo ────────────────────────────────────
//
// An inline code span whose text begins with one of this repo's own top-level
// directories, `ANCHORS` below. That is what makes the check answerable: a
// chapter also quotes `server/index.js` loosely, and `sidecar/src/store.rs`,
// which lives in another repo entirely and cannot be resolved here. Anchoring on
// our own directory names means every token this check reads is a claim it can
// actually settle.
//
// **The anchors are stated, not derived from the tree**, and that is deliberate
// for the reason core's own build guard states it (MODULE_API.md §3.6): a list
// derived from what exists cannot fail when what exists changes. Rename
// `template/` and a derived anchor set would simply stop checking every
// `template/…` mention in the book, silently, at the moment they all became
// wrong. So the anchors are written down — and each one must exist, or this check
// fails. An anchor that has stopped matching is a check that has stopped
// checking, the same rule the identifier exemptions in core's CI follow.
//
// Fenced blocks are excluded (`lib/markdown.js`). A fence in this book is often a
// listing of the reader's own future tree, and their files are not ours.
//
// Usage: node scripts/checkChapterPaths.js (from the repo root)
const fs = require('fs')
const path = require('path')
const { codeSpans } = require('./lib/markdown')
const ROOT = path.resolve(__dirname, '..')
// This repo's own top-level directories. See the note above on why this is a list
// and not a directory scan.
const ANCHORS = ['template/', 'book/', 'scripts/', 'ci/']
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()
}
/**
* The repo paths a document claims, from its inline code spans.
*
* A span is a claim when it starts with an anchor and names something a
* filesystem could answer for. Three kinds are skipped, each because the answer
* would be "no" for a reason that is not a mistake:
*
* • a placeholder — `template/<id>/…`, `scripts/*.js` — which is a shape rather
* than a path;
* • a span with whitespace in it, which is a phrase or a command line
* (`npm ci --prefix template/server` is not a path and its first word is not
* an anchor either, but a span like `cd template/server && npm test` would
* slip through on its first token without this);
* • trailing prose punctuation, stripped rather than skipped, so `template/`
* ending a sentence still resolves.
*/
function claimedPaths(markdown) {
const found = []
for (const { text, line } of codeSpans(markdown)) {
const token = text.trim()
if (/\s/.test(token)) continue
if (!ANCHORS.some((a) => token.startsWith(a))) continue
if (/[<>*?]|\.\.\./.test(token)) continue
// A path may legitimately end in `/` (a directory); anything else in this set
// is the sentence around it, not part of the name.
const cleaned = token.replace(/[.,;:)\]]+$/, '')
if (cleaned) found.push({ path: cleaned, line })
}
return found
}
/** Everything wrong, as sentences. Empty means every claim resolves. */
function problems({ claims, exists }) {
const out = []
for (const anchor of ANCHORS) {
const dir = anchor.replace(/\/$/, '')
if (!exists(dir)) {
out.push(
`${anchor} is listed as an anchor and does not exist. ` +
'Either restore it or update ANCHORS — an anchor that matches nothing is a ' +
'check that has silently stopped checking.',
)
}
}
for (const { file, path: claimed, line } of claims) {
if (!exists(claimed)) {
out.push(`${file}:${line}: no such path — ${claimed}`)
}
}
return out
}
module.exports = { ANCHORS, claimedPaths, problems, markdownFiles }
if (require.main !== module) return
const files = markdownFiles()
const claims = []
for (const file of files) {
const text = fs.readFileSync(path.join(ROOT, file), 'utf8')
for (const claim of claimedPaths(text)) claims.push({ file, ...claim })
}
const exists = (p) => fs.existsSync(path.join(ROOT, p))
const found = problems({ claims, exists })
if (found.length) {
console.error(`\ncheckChapterPaths: ${found.length} problem(s):\n`)
for (const p of found) console.error(` - ${p}`)
console.error('')
process.exit(1)
}
console.log(
`checkChapterPaths: ${claims.length} path(s) claimed across ${files.length} markdown file(s) — all present.`,
)

View File

@@ -0,0 +1,81 @@
// The chapter-path check, checked.
//
// Same rule as the rename check's own suite: a check written when the thing it
// guards is already clean never fires again, and nothing distinguishes "still
// checking" from "quietly broken" without cases it is required to reject. Every
// "must not catch" case below is a real span that appears in the book.
//
// No filesystem — `problems()` takes `exists` as an argument precisely so it can
// be tested this way, and `claimedPaths()` is pure.
const test = require('node:test')
const assert = require('node:assert')
const { ANCHORS, claimedPaths, problems } = require('./checkChapterPaths')
/** `problems()` over a fixture set of paths that exist. */
const check = (claims, present) =>
problems({ claims, exists: (p) => new Set([...present, ...ANCHORS.map((a) => a.replace(/\/$/, ''))]).has(p) })
test('a claim that resolves is not a problem', () => {
assert.deepStrictEqual(check([{ file: 'book/01.md', line: 3, path: 'template/module.json' }],
['template/module.json']), [])
})
test('a claim that does not resolve fails, naming the file and line', () => {
const found = check([{ file: 'book/02-website-module.md', line: 41, path: 'template/server/gone.js' }], [])
assert.strictEqual(found.length, 1)
assert.match(found[0], /book\/02-website-module\.md:41.*template\/server\/gone\.js/)
})
test('a missing anchor fails on its own', () => {
// The half that keeps this check honest: if `template/` is renamed, every
// template path in the book is wrong AND the check would stop looking at them.
const found = problems({ claims: [], exists: (p) => p !== 'template' })
assert.strictEqual(found.length, 1)
assert.match(found[0], /template\/ is listed as an anchor and does not exist/)
})
test('paths are read only from inline code spans', () => {
const md = 'Open the entry point and read it: template/server/index.js, then stop.'
assert.deepStrictEqual(claimedPaths(md), [])
})
test('a code span inside a fenced block is not a claim', () => {
// A fence is usually the reader's own future tree, and their files are not ours.
const md = ['```', '`template/nope.js`', 'template/also-nope.js', '```'].join('\n')
assert.deepStrictEqual(claimedPaths(md), [])
})
test('a path in another repo is not this check\'s business', () => {
const md = 'The store is `sidecar/src/store.rs`, and the plugin is `overlay/Scripts/Custom/Bridge/BridgeLink.cs`.'
assert.deepStrictEqual(claimedPaths(md), [])
})
test('a placeholder shape is not a path', () => {
const md = 'Your copy lands at `template/<id>/module.json`, and the checks are `scripts/*.js`.'
assert.deepStrictEqual(claimedPaths(md), [])
})
test('a command line is not a path', () => {
// The first token is an anchor in neither case, but a span that BEGINS with one
// and carries arguments would otherwise be read as a filename with spaces in it.
const md = 'Run `npm ci --prefix template/server`, or `template/server && npm test` if you must.'
assert.deepStrictEqual(claimedPaths(md), [])
})
test('trailing sentence punctuation is stripped, not skipped', () => {
const md = 'It all lives under `template/`.'
assert.deepStrictEqual(claimedPaths(md), [{ path: 'template/', line: 1 }])
})
test('a claim on a later line reports that line', () => {
const md = ['# Title', '', 'See `template/server/boot.js`.'].join('\n')
assert.deepStrictEqual(claimedPaths(md), [{ path: 'template/server/boot.js', line: 3 }])
})
test('every anchor is a directory of this repo', () => {
// Stated, not derived (see the header) — so this asserts the stated list is
// still the real one at the moment it is written down.
assert.ok(ANCHORS.every((a) => a.endsWith('/')), 'anchors are directory prefixes')
})

View File

@@ -43,8 +43,9 @@ 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).')
console.error(' ci/core-ref.json names a core from before the module system existed')
console.error(' (it reached `main` at the 2026-08-12 cutover, so any ref older than')
console.error(' that on `main` has no server/src/modules/ at all).')
process.exit(1)
}

View File

@@ -19,11 +19,19 @@
const fs = require('fs')
const path = require('path')
const { stripFences } = require('./lib/markdown')
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'])
// Directories that hold no prose we own. `.core` and `core` are core's own
// checkout: .gitignore reserves both because moving `ci/core-ref.json` means
// cloning core in here first, and without this that clone hands the reader nine
// broken links in somebody else's README the moment they follow the pin-bump
// instructions. CI never saw it — the clone happens in the `template` job and
// this check runs in `prose` — which is exactly the kind of failure that only
// ever meets a person.
const SKIP_DIRS = new Set(['.git', 'node_modules', 'dist', '.core', 'core'])
/** Every markdown file in the repo, repo-relative, sorted. */
function markdownFiles(dir = ROOT, out = []) {
@@ -38,31 +46,10 @@ function markdownFiles(dir = ROOT, out = []) {
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')
}
// Fenced code blocks are stripped before links are read (`lib/markdown.js`): 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.
/** Inline `[text](target)` links and `[ref]: target` definitions, with line numbers. */
function linksIn(text) {

View File

@@ -34,9 +34,23 @@ const TEMPLATE = path.join(ROOT, 'template')
const CHECKLIST = path.join(TEMPLATE, 'README.md')
// Anything a rename has to touch: the id (`examplegame`), the display name
// ("Example Game"), and the placeholder world ("Example World"). One pattern
// rather than three, because they are one decision.
const PLACEHOLDER = /example[ -]?(game|world)/i
// ("Example Game"), the placeholder world ("Example World"), and the two
// publishing placeholders in the Gitea release workflow (`gitea.example.com`,
// `your-org/your-module`). One pattern rather than four, because they are one
// decision — everything a reader must change before this template is theirs.
//
// **Every alternative has to be a string that cannot occur by accident**, which
// is the same rule that made the id `examplegame` rather than `example` (see the
// header). The publishing pair was added after the acceptance run found the
// release workflow carrying `# CHANGE THESE` placeholders that the checklist did
// not list and this pattern could not see — CI silent by construction
// (docs/modules/kit-acceptance.md, F3).
//
// The near-miss is worth keeping: the obvious widening is `example\.com`, and it
// is WRONG. `server/test/checkImports.test.js` uses `https://example.com/x` as a
// fixture — a URL in a string, testing that a URL in a string is not an import —
// and it is not a rename site. The host is matched in full instead.
const PLACEHOLDER = /example[ -]?(game|world)|gitea\.example\.com|your-(org|module)/i
// Directories with nothing of ours in them. `dist` and `node_modules` are build
// output — a chunk full of the placeholder is not a rename site, it is the

View File

@@ -61,6 +61,10 @@ test('the placeholder pattern matches every form a rename touches', () => {
"worldName: 'Example World'",
'examplegame_world_status',
'example-game',
// The publishing pair, added after the acceptance run found the release
// workflow unlisted and unmatchable (kit-acceptance.md F3).
' GITEA_HOST: gitea.example.com',
' REPO: your-org/your-module',
]) {
assert.ok(PLACEHOLDER.test(text), `should match: ${text}`)
}
@@ -75,6 +79,14 @@ test('the placeholder pattern does not fire on ordinary prose', () => {
'an example of what to catch',
'exampleValue',
'the game world',
// A real span from server/test/checkImports.test.js, and the reason the
// publishing host is matched in full rather than as `example.com`: it is a
// fixture URL inside a string, in a test about URLs inside strings, and it is
// not a rename site. The obvious widening would have failed the build on it.
"const url = 'https://example.com/x'",
// Prose about the reader's own module, which is not the hyphenated token.
'copy your module directory onto the volume',
'your org will need a release token',
]) {
assert.ok(!PLACEHOLDER.test(text), `should not match: ${text}`)
}

60
scripts/lib/markdown.js Normal file
View File

@@ -0,0 +1,60 @@
// The two pieces of markdown handling both checks in this directory need, in one
// place rather than two copies that drift.
//
// Shared code, not a shared description. Core's own loader and its schema replay
// use one splitter for the same reason (MODULE_API.md §2.6): two implementations
// of "what counts as a code fence" would disagree eventually, and the check that
// disagreed quietly would be the one still reporting green.
/**
* The text with every fenced code block blanked out, line count preserved.
*
* Fenced blocks are stripped before either check reads anything, because a fence
* can legitimately contain a path or a link that does not exist: a directory
* listing of the project the reader has not created yet, a URL in an example. In
* a repo made entirely of that document type, flagging them makes the check
* useless.
*
* Done 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. Lines are replaced by empty strings rather than removed so that
* line numbers in a report still point at the right place.
*/
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')
}
/**
* Every inline code span outside a fenced block, with the 1-based line it is on.
*
* `[a](b)` inside backticks is an example rather than a link, and `template/x.js`
* inside backticks is a claim about this repo's tree — which is why one check
* throws these away and the other reads only these.
*/
function codeSpans(text) {
const found = []
stripFences(text).split(/\r?\n/).forEach((line, i) => {
for (const m of line.matchAll(/`([^`]+)`/g)) {
found.push({ text: m[1], line: i + 1 })
}
})
return found
}
module.exports = { stripFences, codeSpans }

22
template/.gitattributes vendored Normal file
View File

@@ -0,0 +1,22 @@
# Check every text file out with LF, on every platform.
#
# This exists because of a real failure, reported by the kit's acceptance run
# (docs/modules/kit-acceptance.md, finding F1): on a default Windows clone,
# `npm run check:swagger` failed on a PRISTINE, unedited template. The check
# compares the committed swagger-fragment.json against what the generator writes;
# the generator writes LF, and core.autocrlf had handed the reader CRLF. The
# message blamed "the routes or their annotations", which is the first command the
# kit tells a reader to run telling them a false thing about their own work.
#
# The check itself now normalises line endings before comparing, so this file is
# the belt to that pair of braces: it also stops a CRLF blob ever being COMMITTED
# by a reader who copies this template, which would break the same check for
# everyone who cloned their repo afterwards.
#
# It travels with the template on purpose — a copied module directory keeps its
# own attributes, and this is one of the things the copier should not have to know.
* text=auto eol=lf
# Nothing here is binary today. If your module ships an image or a font, mark it,
# because `text=auto` guesses and a wrong guess corrupts the file:
# *.png binary

View File

@@ -19,19 +19,45 @@
# install downloads the tarball, verifies it against the `sha256` in the manifest,
# and unpacks it onto the volume. Nothing runs `npm` on the way.
#
# ── The version is DECLARED, not derived ──────────────────────────────────
# ── The version is DERIVED, and `module.json` is a floor ──────────────────
#
# Your module already has one authoritative version: `module.json`'s. It is what
# core records in `installed_modules`, what the admin screen shows, and it sits
# beside the `coreApi` range you have to consider a bump against. Two sources for
# one number is how they drift — so **a release happens when a push to `main`
# leaves `module.json` at a version that has no release yet.** Bumping the version
# is an ordinary reviewed pull request; publishing is this file's business.
# **Every push to `main` carrying a releasable commit publishes a bundle.** The
# next version is computed from conventional-commit subjects since the newest
# `v*` tag:
#
# It follows that this workflow never writes to a branch. It tags and publishes,
# so a protected `main` needs no push exception — which matters, because a release
# engine that has to push to `main` stops working the day someone tightens the
# rule. Re-running on an already-released version is a no-op.
# feat!: / BREAKING CHANGE -> major feat: -> minor fix|perf: -> patch
# nothing releasable -> no release is cut
# (first ever run, no tag) -> releases what module.json declares
#
# The obvious alternative is to let `module.json`'s version decide — you already
# have that number, it is what core records in `installed_modules` and what the
# admin screen shows, and two sources for one number is how they drift. This
# repository's reference module shipped that way and moved off it, which is worth
# knowing before you copy either shape: the cost of a declared version is paid on
# **every** release, and the drift it prevents is something review catches anyway.
# A week of merged work there produced no bundle at all, because none of it
# happened to touch that line.
#
# **The declaration is kept as a floor.** Name a version in `module.json` above
# the newest tag and that version is what releases — which is the declared model
# exactly, surviving as the special case it always was, and still the natural way
# to say "this one is a minor" when a `coreApi` bump forces the question.
#
# So the number that ships is the **tag**, and this job writes it into the
# `module.json` inside the bundle. Your committed `module.json` is a floor and a
# starting point, not a record of the last release.
#
# ── The backdoor ──────────────────────────────────────────────────────────
#
# `workflow_dispatch` publishes on demand, for the case the rules cannot reach: a
# `module.json` change worth shipping — a widened `coreApi`, a new mount, a new
# capability — with no releasable code behind it. Leave `version` blank to bump
# the newest tag by `bump`, or name an exact version to publish that.
#
# This workflow never writes to a branch. It tags and publishes, so a protected
# `main` needs no push exception — which matters, because a release engine that
# has to push to `main` stops working the day someone tightens the rule.
# Re-running on an already-released version is a no-op.
#
# ── Before this can run ───────────────────────────────────────────────────
#
@@ -44,6 +70,16 @@ name: Release
on:
push:
branches: [main]
workflow_dispatch:
inputs:
version:
description: 'Exact version to publish (e.g. 0.2.1). Blank = bump the newest tag by the level below.'
required: false
default: ''
bump:
description: 'Bump level when version is blank: patch | minor | major'
required: false
default: 'patch'
concurrency:
group: release-module
@@ -67,34 +103,157 @@ jobs:
with:
node-version: 20
- name: Decide whether this commit releases
- name: Plan the release (version + changelog)
id: plan
env:
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
EVENT: ${{ github.event_name }}
IN_VERSION: ${{ github.event.inputs.version }}
IN_BUMP: ${{ github.event.inputs.bump }}
run: |
set -euo pipefail
mkdir -p dist
git fetch --tags --force >/dev/null 2>&1 || true
ID="$(node -p "require('./module.json').id")"
VERSION="$(node -p "require('./module.json').version")"
echo "module.json: ${ID} ${VERSION}"
DECLARED="$(node -p "require('./module.json').version")"
LAST_TAG="$(git describe --tags --match 'v*' --abbrev=0 2>/dev/null || true)"
CURRENT="${LAST_TAG#v}"
RANGE="${LAST_TAG:+${LAST_TAG}..}HEAD"
echo "module.json: ${ID}, declaring ${DECLARED}; newest tag is ${LAST_TAG:-<none>}"
# Does a release already exist for this version? 404 means no, 200 means
# yes, and anything else — a network failure, a bad token — is not
# evidence of absence. Guessing "no" would publish over a good release,
# so refuse instead.
HTTP="$(curl -s -o /dev/null -w '%{http_code}' \
-H "Authorization: token $(printf '%s' "${REGISTRY_TOKEN:-}" | tr -d '\r\n')" \
"https://${GITEA_HOST}/api/v1/repos/${REPO}/releases/tags/v${VERSION}" || echo 000)"
SUBJECTS="$(git log --no-merges --format='%s' $RANGE || true)"
BODIES="$(git log --no-merges --format='%B' $RANGE || true)"
case "$HTTP" in
404) RELEASE=true ;;
200) RELEASE=false; echo "v${VERSION} is already released — nothing to do." ;;
*) echo "::error::Could not determine whether v${VERSION} is released (HTTP ${HTTP}). Refusing to guess."; exit 1 ;;
esac
BUMP=none
if echo "$BODIES" | grep -qE 'BREAKING[ -]CHANGE' ; then BUMP=major; fi
if echo "$SUBJECTS" | grep -qE '^[a-z]+(\([^)]+\))?!:' ; then BUMP=major; fi
if [ "$BUMP" = none ] && echo "$SUBJECTS" | grep -qE '^feat(\([^)]+\))?:' ; then BUMP=minor; fi
if [ "$BUMP" = none ] && echo "$SUBJECTS" | grep -qE '^(fix|perf)(\([^)]+\))?:' ; then BUMP=patch; fi
echo "id=${ID}" >> "$GITHUB_OUTPUT"
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
echo "tag=v${VERSION}" >> "$GITHUB_OUTPUT"
echo "release=${RELEASE}" >> "$GITHUB_OUTPUT"
bump() { # <x.y.z> <major|minor|patch> -> bumped
IFS=. read -r MA MI PA <<< "$1"
case "$2" in
major) echo "$((MA+1)).0.0" ;;
minor) echo "${MA}.$((MI+1)).0" ;;
patch) echo "${MA}.${MI}.$((PA+1))" ;;
esac
}
# `sort -V` orders version strings, so the higher of two is its last
# line — used rather than a hand-rolled compare because 0.10.0 vs 0.9.0
# is exactly what a plain string sort gets wrong.
higher() { printf '%s\n%s\n' "$1" "$2" | sort -V | tail -1; }
rank() { case "$1" in major) echo 3 ;; minor) echo 2 ;; patch) echo 1 ;; *) echo 0 ;; esac; }
bigger_bump() { if [ "$(rank "$1")" -ge "$(rank "$2")" ]; then echo "$1"; else echo "$2"; fi; }
VERSION=""
if [ -n "${IN_VERSION:-}" ]; then
VERSION="${IN_VERSION}"
echo "dispatch: publishing the requested version ${VERSION}"
else
LEVEL="$BUMP"
# A dispatch with nothing releasable still releases — that is what the
# button is for. Where the log does say something, the LARGER of the
# two wins: pressing the button on a log full of `feat:` would
# otherwise publish the `patch` default over a minor's worth of work.
if [ "${EVENT:-}" = workflow_dispatch ]; then
LEVEL="$(bigger_bump "$LEVEL" "${IN_BUMP:-patch}")"
if [ "$BUMP" = none ]; then
echo "dispatch: nothing releasable in the log, bumping ${LEVEL} anyway"
else
echo "dispatch: the log says ${BUMP}, publishing a ${LEVEL}"
fi
fi
if [ -z "$CURRENT" ]; then
VERSION="$DECLARED" # first ever release: ship what is declared
elif [ "$LEVEL" != none ]; then
VERSION="$(bump "$CURRENT" "$LEVEL")"
fi
# The floor: a module.json above the newest tag releases at that
# version, whatever the subjects say.
if [ -n "$CURRENT" ] && [ "$DECLARED" != "$CURRENT" ] \
&& [ "$(higher "$DECLARED" "$CURRENT")" = "$DECLARED" ]; then
if [ -z "$VERSION" ] || [ "$(higher "$DECLARED" "$VERSION")" = "$DECLARED" ]; then
echo "module.json declares ${DECLARED}, above both ${CURRENT} and the derived version — releasing that."
VERSION="$DECLARED"
fi
fi
fi
RELEASE=true
if [ -z "$VERSION" ]; then
RELEASE=false
VERSION="$CURRENT"
echo "Nothing releasable since ${LAST_TAG} (no feat/fix/perf/breaking subject) — standing down."
fi
# An existing tag is NOT automatically "nothing to do". A tag with no
# release behind it means a previous run tagged and then died before
# publishing, and standing down on the tag alone would make that state
# permanent — every later run sees the tag and stands down, so the
# release never appears. 404 means no release, 200 means yes, and
# anything else — a network failure, a bad token — is not evidence of
# absence: guessing "no" would publish over a good release, so refuse.
REUSE_TAG=false
if [ -n "$VERSION" ] && git rev-parse -q --verify "refs/tags/v${VERSION}" >/dev/null; then
HTTP="$(curl -s -o /dev/null -w '%{http_code}' \
-H "Authorization: token $(printf '%s' "${REGISTRY_TOKEN:-}" | tr -d '\r\n')" \
"https://${GITEA_HOST}/api/v1/repos/${REPO}/releases/tags/v${VERSION}" || echo 000)"
case "$HTTP" in
200) echo "v${VERSION} is already released — nothing to do."; RELEASE=false ;;
404) echo "::warning::Tag v${VERSION} exists but has no release — a previous run failed after tagging. Reusing the tag and publishing the release it is missing."
REUSE_TAG=true; RELEASE=true ;;
*) echo "::error::Could not determine whether v${VERSION} is released (HTTP ${HTTP}). Refusing to guess."; exit 1 ;;
esac
fi
# Changelog range. A recovery run has nothing after the tag, so
# summarize what the tag itself contains: previous-tag..this-tag.
if [ "$REUSE_TAG" = true ]; then
PREV_TAG="$(git describe --tags --match 'v*' --abbrev=0 "v${VERSION}^" 2>/dev/null || true)"
CL_RANGE="${PREV_TAG:+${PREV_TAG}..}v${VERSION}"
SINCE="$PREV_TAG"
else
CL_RANGE="$RANGE"
SINCE="$LAST_TAG"
fi
CL_SUBJECTS="$(git log --no-merges --format='%s' $CL_RANGE || true)"
{
echo "## ${ID} v${VERSION}"
echo
echo "Install from the website's Admin → Modules screen by pasting the URL of"
echo "\`${ID}-${VERSION}.json\`, or unpack the tarball onto the modules volume as"
echo "\`modules/${ID}/\`. Requires a core whose \`MODULE_API_VERSION\` satisfies"
echo "\`$(node -p "require('./module.json').coreApi")\`."
echo
FEATS="$(echo "$CL_SUBJECTS" | grep -E '^feat' || true)"
FIXES="$(echo "$CL_SUBJECTS" | grep -E '^(fix|perf)' || true)"
[ -n "$FEATS" ] && { echo "### Features"; echo "$FEATS" | sed 's/^/- /'; echo; }
[ -n "$FIXES" ] && { echo "### Fixes"; echo "$FIXES" | sed 's/^/- /'; echo; }
echo "### All changes"
if [ -n "$SINCE" ]; then echo "Since ${SINCE}:"; fi
echo "$CL_SUBJECTS" | sed 's/^/- /'
echo
echo "### Verifying this download"
echo
echo "Releases are **unsigned** — the \`sha256\` in \`${ID}-${VERSION}.json\` is the"
echo "trust anchor, and the website verifies it before unpacking."
echo
echo '```bash'
echo "sha256sum -c SHA256SUMS --ignore-missing"
echo '```'
} > dist/CHANGELOG.md
echo "id=${ID}" >> "$GITHUB_OUTPUT"
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
echo "tag=v${VERSION}" >> "$GITHUB_OUTPUT"
echo "release=${RELEASE}" >> "$GITHUB_OUTPUT"
echo "reuse_tag=${REUSE_TAG}" >> "$GITHUB_OUTPUT"
echo "==> release=${RELEASE} version=${VERSION} bump=${BUMP} declared=${DECLARED} last_tag=${LAST_TAG:-<none>}"
# Before anything is built or tagged, so a repo without secrets fails
# legibly rather than half-publishing: the tag push can succeed on the
@@ -142,21 +301,40 @@ jobs:
ID="${{ steps.plan.outputs.id }}"
VERSION="${{ steps.plan.outputs.version }}"
OUT="dist/${ID}-${VERSION}"
rm -rf dist && mkdir -p "$OUT"
# `$OUT`, not `dist` — the changelog is already sitting in `dist/` from
# the plan step, and the publish step reads it back.
rm -rf "$OUT" && mkdir -p "$OUT"
# The manifest core reads, the OpenAPI fragment, and the licence the
# code is under — a bundle shipping GPL code without its licence is not
# distributable.
cp module.json swagger-fragment.json LICENSE.md README.md "$OUT/"
# The manifest core reads — with the RELEASED version written into it.
# Your committed `module.json` is a floor, not a record of the last
# release, so copying it verbatim would ship a bundle whose
# `installed_modules` row and admin screen disagree with the tag it came
# from. This is where the derived number becomes the module's own.
jq --arg v "$VERSION" '.version = $v' module.json > "$OUT/module.json"
# The OpenAPI fragment, and the licence the code is under — a bundle
# shipping GPL code without its licence is not distributable.
cp swagger-fragment.json LICENSE.md README.md "$OUT/"
# The server half, minus everything that never runs inside core's
# process: no `test/`, no `scripts/`, no `swagger/`.
#
# An EXCLUSION list, not an include list, and that is the whole point.
# This was `for d in boot.js core.js index.js db model router`, which
# meant adding `server/utils/` — an ordinary thing to do — silently
# dropped it from every release: the bundle check below only resolves
# the five paths module.json names, so nothing failed here, and the
# module died on an operator's box as a `startup_failed` row instead
# (docs/modules/kit-acceptance.md, F4). Excluding is the safe default
# because the failure mode inverts: forget to exclude something and you
# ship a harmless extra file, rather than omitting a required one.
mkdir -p "$OUT/server"
for d in boot.js core.js index.js db model router; do
cp -r "server/$d" "$OUT/server/"
for e in server/*; do
case "$(basename "$e")" in
test|scripts|swagger|package-lock.json) continue ;;
esac
cp -r "$e" "$OUT/server/"
done
cp server/package.json "$OUT/server/"
[ -d server/node_modules ] && cp -r server/node_modules "$OUT/server/" || true
# The client half is the BUILT chunk only. `client/src` is source an
# operator has no use for and core will never read.
@@ -166,17 +344,23 @@ jobs:
# Prove the bundle is loadable before publishing it: these are the exact
# paths core's loader resolves out of module.json. A release whose entry
# point is missing otherwise fails on an operator's box, as a
# `startup_failed` row, instead of here.
# `startup_failed` row, instead of here. The version assertion guards the
# rewrite above — a bundle still carrying the declared version would
# install under a number that is not the one it was released as.
node -e '
const fs = require("fs"), path = require("path");
const root = process.argv[1];
const [root, want] = process.argv.slice(1);
const m = JSON.parse(fs.readFileSync(path.join(root, "module.json"), "utf8"));
if (m.version !== want) {
console.error(`bundle declares ${m.version}, but this is release ${want}`);
process.exit(1);
}
for (const p of [m.server, m.schema, m.purge, m.client && m.client.entry, "swagger-fragment.json"]) {
if (!p) continue;
if (!fs.existsSync(path.join(root, p))) { console.error("bundle is missing " + p); process.exit(1); }
}
console.log("bundle contents check: ok");
' "$OUT"
' "$OUT" "$VERSION"
tar -C dist -czf "dist/${ID}-${VERSION}.tar.gz" "${ID}-${VERSION}"
rm -rf "$OUT"
@@ -202,38 +386,10 @@ jobs:
echo "${SHA} ${ID}-${VERSION}.tar.gz" > dist/SHA256SUMS
cat "dist/${ID}-${VERSION}.json"
- name: Write the changelog
if: ${{ steps.plan.outputs.release == 'true' }}
run: |
set -euo pipefail
ID="${{ steps.plan.outputs.id }}"
VERSION="${{ steps.plan.outputs.version }}"
LAST_TAG="$(git describe --tags --match 'v*' --abbrev=0 2>/dev/null || true)"
RANGE="${LAST_TAG:+${LAST_TAG}..}HEAD"
{
echo "## ${ID} v${VERSION}"
echo
echo "Install from the website's Admin → Modules screen by pasting the URL of"
echo "\`${ID}-${VERSION}.json\`, or unpack the tarball onto the modules volume as"
echo "\`modules/${ID}/\`. Requires a core whose \`MODULE_API_VERSION\` satisfies"
echo "\`$(node -p "require('./module.json').coreApi")\`."
echo
echo "### Changes"
if [ -n "$LAST_TAG" ]; then echo "Since ${LAST_TAG}:"; fi
git log --no-merges --format='- %s' $RANGE || true
echo
echo "### Verifying this download"
echo
echo "Releases are **unsigned** — the \`sha256\` in \`${ID}-${VERSION}.json\` is the"
echo "trust anchor, and the website verifies it before unpacking."
echo
echo '```bash'
echo "sha256sum -c SHA256SUMS --ignore-missing"
echo '```'
} > dist/CHANGELOG.md
# Skipped on a recovery run: the tag is already there, and is the thing
# being published against.
- name: Tag the release
if: ${{ steps.plan.outputs.release == 'true' }}
if: ${{ steps.plan.outputs.release == 'true' && steps.plan.outputs.reuse_tag != 'true' }}
run: |
set -euo pipefail
TAG="${{ steps.plan.outputs.tag }}"

View File

@@ -23,13 +23,33 @@
# downloads the tarball, verifies it against the `sha256` in the manifest, and
# unpacks it onto the volume. Nothing runs `npm` on the way.
#
# ── The version is DECLARED, not derived ──────────────────────────────────
# ── The version is DERIVED, and `module.json` is a floor ──────────────────
#
# Your module already has one authoritative version: `module.json`'s. It is what
# core records in `installed_modules` and what the admin screen shows. Two sources
# for one number is how they drift — so **a release happens when a push to `main`
# leaves `module.json` at a version that has no release yet.** Bumping the version
# is an ordinary reviewed pull request; publishing is this file's business.
# **Every push to `main` carrying a releasable commit publishes a bundle.** The
# next version is computed from conventional-commit subjects since the newest
# `v*` tag:
#
# feat!: / BREAKING CHANGE -> major feat: -> minor fix|perf: -> patch
# nothing releasable -> no release is cut
# (first ever run, no tag) -> releases what module.json declares
#
# The obvious alternative is to let `module.json`'s version decide — you already
# have that number, and two sources for one number is how they drift. This
# repository's reference module shipped that way and moved off it, which is worth
# knowing before you copy either shape: the cost of a declared version is paid on
# **every** release, and the drift it prevents is something review catches anyway.
# A week of merged work there produced no bundle at all, because none of it
# happened to touch that line.
#
# **The declaration is kept as a floor.** Name a version in `module.json` above
# the newest tag and that version is what releases — the declared model surviving
# as the special case it always was, and still the natural way to say "this one
# is a minor" when a `coreApi` bump forces the question.
#
# So the number that ships is the **tag**, and this job writes it into the
# `module.json` inside the bundle. `workflow_dispatch` is the backdoor for a
# `module.json` change worth shipping with no releasable code behind it: leave
# `version` blank to bump the newest tag by `bump`, or name an exact version.
#
# This workflow never writes to a branch, so a protected `main` needs no push
# exception. Re-running on an already-released version is a no-op.
@@ -44,6 +64,16 @@ name: Release
on:
push:
branches: [main]
workflow_dispatch:
inputs:
version:
description: 'Exact version to publish (e.g. 0.2.1). Blank = bump the newest tag by the level below.'
required: false
default: ''
bump:
description: 'Bump level when version is blank: patch | minor | major'
required: false
default: 'patch'
permissions:
contents: write
@@ -65,36 +95,162 @@ jobs:
with:
node-version: 20
- name: Decide whether this commit releases
- name: Plan the release (version + changelog)
id: plan
env:
GH_TOKEN: ${{ github.token }}
EVENT: ${{ github.event_name }}
IN_VERSION: ${{ github.event.inputs.version }}
IN_BUMP: ${{ github.event.inputs.bump }}
run: |
set -euo pipefail
mkdir -p dist
git fetch --tags --force >/dev/null 2>&1 || true
ID="$(node -p "require('./module.json').id")"
VERSION="$(node -p "require('./module.json').version")"
echo "module.json: ${ID} ${VERSION}"
DECLARED="$(node -p "require('./module.json').version")"
LAST_TAG="$(git describe --tags --match 'v*' --abbrev=0 2>/dev/null || true)"
CURRENT="${LAST_TAG#v}"
RANGE="${LAST_TAG:+${LAST_TAG}..}HEAD"
echo "module.json: ${ID}, declaring ${DECLARED}; newest tag is ${LAST_TAG:-<none>}"
# `gh release view` exits non-zero when the release does not exist — but
# it also exits non-zero when the API is unreachable, and those two are
# not the same answer. Ask for the status code instead: 404 means no,
# 200 means yes, anything else is not evidence of absence, and guessing
# "no" would publish over a good release.
HTTP="$(curl -s -o /dev/null -w '%{http_code}' \
-H "Authorization: Bearer ${GH_TOKEN}" \
-H "Accept: application/vnd.github+json" \
"${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/releases/tags/v${VERSION}" || echo 000)"
SUBJECTS="$(git log --no-merges --format='%s' $RANGE || true)"
BODIES="$(git log --no-merges --format='%B' $RANGE || true)"
case "$HTTP" in
404) RELEASE=true ;;
200) RELEASE=false; echo "v${VERSION} is already released — nothing to do." ;;
*) echo "::error::Could not determine whether v${VERSION} is released (HTTP ${HTTP}). Refusing to guess."; exit 1 ;;
esac
BUMP=none
if echo "$BODIES" | grep -qE 'BREAKING[ -]CHANGE' ; then BUMP=major; fi
if echo "$SUBJECTS" | grep -qE '^[a-z]+(\([^)]+\))?!:' ; then BUMP=major; fi
if [ "$BUMP" = none ] && echo "$SUBJECTS" | grep -qE '^feat(\([^)]+\))?:' ; then BUMP=minor; fi
if [ "$BUMP" = none ] && echo "$SUBJECTS" | grep -qE '^(fix|perf)(\([^)]+\))?:' ; then BUMP=patch; fi
echo "id=${ID}" >> "$GITHUB_OUTPUT"
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
echo "tag=v${VERSION}" >> "$GITHUB_OUTPUT"
echo "release=${RELEASE}" >> "$GITHUB_OUTPUT"
bump() { # <x.y.z> <major|minor|patch> -> bumped
IFS=. read -r MA MI PA <<< "$1"
case "$2" in
major) echo "$((MA+1)).0.0" ;;
minor) echo "${MA}.$((MI+1)).0" ;;
patch) echo "${MA}.${MI}.$((PA+1))" ;;
esac
}
# `sort -V` orders version strings, so the higher of two is its last
# line — used rather than a hand-rolled compare because 0.10.0 vs 0.9.0
# is exactly what a plain string sort gets wrong.
higher() { printf '%s\n%s\n' "$1" "$2" | sort -V | tail -1; }
rank() { case "$1" in major) echo 3 ;; minor) echo 2 ;; patch) echo 1 ;; *) echo 0 ;; esac; }
bigger_bump() { if [ "$(rank "$1")" -ge "$(rank "$2")" ]; then echo "$1"; else echo "$2"; fi; }
VERSION=""
if [ -n "${IN_VERSION:-}" ]; then
VERSION="${IN_VERSION}"
echo "dispatch: publishing the requested version ${VERSION}"
else
LEVEL="$BUMP"
# A dispatch with nothing releasable still releases — that is what the
# button is for. Where the log does say something, the LARGER of the
# two wins: pressing the button on a log full of `feat:` would
# otherwise publish the `patch` default over a minor's worth of work.
if [ "${EVENT:-}" = workflow_dispatch ]; then
LEVEL="$(bigger_bump "$LEVEL" "${IN_BUMP:-patch}")"
if [ "$BUMP" = none ]; then
echo "dispatch: nothing releasable in the log, bumping ${LEVEL} anyway"
else
echo "dispatch: the log says ${BUMP}, publishing a ${LEVEL}"
fi
fi
if [ -z "$CURRENT" ]; then
VERSION="$DECLARED" # first ever release: ship what is declared
elif [ "$LEVEL" != none ]; then
VERSION="$(bump "$CURRENT" "$LEVEL")"
fi
# The floor: a module.json above the newest tag releases at that
# version, whatever the subjects say.
if [ -n "$CURRENT" ] && [ "$DECLARED" != "$CURRENT" ] \
&& [ "$(higher "$DECLARED" "$CURRENT")" = "$DECLARED" ]; then
if [ -z "$VERSION" ] || [ "$(higher "$DECLARED" "$VERSION")" = "$DECLARED" ]; then
echo "module.json declares ${DECLARED}, above both ${CURRENT} and the derived version — releasing that."
VERSION="$DECLARED"
fi
fi
fi
RELEASE=true
if [ -z "$VERSION" ]; then
RELEASE=false
VERSION="$CURRENT"
echo "Nothing releasable since ${LAST_TAG} (no feat/fix/perf/breaking subject) — standing down."
fi
# A tag with no release behind it is NOT "nothing to do": it means a
# previous run tagged and then died before publishing, and standing down
# on the tag alone makes that state permanent. `gh release view` exits
# non-zero when the release does not exist — but it also exits non-zero
# when the API is unreachable, and those two are not the same answer.
# Ask for the status code instead: 404 means no, 200 means yes, anything
# else is not evidence of absence, and guessing "no" would publish over
# a good release.
REUSE_TAG=false
if [ -n "$VERSION" ] && git rev-parse -q --verify "refs/tags/v${VERSION}" >/dev/null; then
HTTP="$(curl -s -o /dev/null -w '%{http_code}' \
-H "Authorization: Bearer ${GH_TOKEN}" \
-H "Accept: application/vnd.github+json" \
"${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/releases/tags/v${VERSION}" || echo 000)"
case "$HTTP" in
200) echo "v${VERSION} is already released — nothing to do."; RELEASE=false ;;
404) echo "::warning::Tag v${VERSION} exists but has no release — a previous run failed after tagging. Reusing the tag and publishing the release it is missing."
REUSE_TAG=true; RELEASE=true ;;
*) echo "::error::Could not determine whether v${VERSION} is released (HTTP ${HTTP}). Refusing to guess."; exit 1 ;;
esac
fi
# Changelog range. A recovery run has nothing after the tag, so
# summarize what the tag itself contains: previous-tag..this-tag.
if [ "$REUSE_TAG" = true ]; then
PREV_TAG="$(git describe --tags --match 'v*' --abbrev=0 "v${VERSION}^" 2>/dev/null || true)"
CL_RANGE="${PREV_TAG:+${PREV_TAG}..}v${VERSION}"
SINCE="$PREV_TAG"
else
CL_RANGE="$RANGE"
SINCE="$LAST_TAG"
fi
CL_SUBJECTS="$(git log --no-merges --format='%s' $CL_RANGE || true)"
{
echo "## ${ID} v${VERSION}"
echo
echo "Install from the website's Admin → Modules screen by pasting the URL of"
echo "\`${ID}-${VERSION}.json\`, or unpack the tarball onto the modules volume as"
echo "\`modules/${ID}/\`. Requires a core whose \`MODULE_API_VERSION\` satisfies"
echo "\`$(node -p "require('./module.json').coreApi")\`."
echo
echo "The website only installs from hosts on its \`MODULE_SOURCE_HOSTS\` allowlist —"
echo "an operator installing this needs \`github.com\` on theirs."
echo
FEATS="$(echo "$CL_SUBJECTS" | grep -E '^feat' || true)"
FIXES="$(echo "$CL_SUBJECTS" | grep -E '^(fix|perf)' || true)"
[ -n "$FEATS" ] && { echo "### Features"; echo "$FEATS" | sed 's/^/- /'; echo; }
[ -n "$FIXES" ] && { echo "### Fixes"; echo "$FIXES" | sed 's/^/- /'; echo; }
echo "### All changes"
if [ -n "$SINCE" ]; then echo "Since ${SINCE}:"; fi
echo "$CL_SUBJECTS" | sed 's/^/- /'
echo
echo "### Verifying this download"
echo
echo "Releases are **unsigned** — the \`sha256\` in \`${ID}-${VERSION}.json\` is the"
echo "trust anchor, and the website verifies it before unpacking."
echo
echo '```bash'
echo "sha256sum -c SHA256SUMS --ignore-missing"
echo '```'
} > dist/CHANGELOG.md
echo "id=${ID}" >> "$GITHUB_OUTPUT"
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
echo "tag=v${VERSION}" >> "$GITHUB_OUTPUT"
echo "release=${RELEASE}" >> "$GITHUB_OUTPUT"
echo "reuse_tag=${REUSE_TAG}" >> "$GITHUB_OUTPUT"
echo "==> release=${RELEASE} version=${VERSION} bump=${BUMP} declared=${DECLARED} last_tag=${LAST_TAG:-<none>}"
- name: Build the client chunk
if: ${{ steps.plan.outputs.release == 'true' }}
@@ -126,21 +282,40 @@ jobs:
ID="${{ steps.plan.outputs.id }}"
VERSION="${{ steps.plan.outputs.version }}"
OUT="dist/${ID}-${VERSION}"
rm -rf dist && mkdir -p "$OUT"
# `$OUT`, not `dist` — the changelog is already sitting in `dist/` from
# the plan step, and the publish step reads it back.
rm -rf "$OUT" && mkdir -p "$OUT"
# The manifest core reads, the OpenAPI fragment, and the licence the
# code is under — a bundle shipping GPL code without its licence is not
# distributable.
cp module.json swagger-fragment.json LICENSE.md README.md "$OUT/"
# The manifest core reads — with the RELEASED version written into it.
# Your committed `module.json` is a floor, not a record of the last
# release, so copying it verbatim would ship a bundle whose
# `installed_modules` row and admin screen disagree with the tag it came
# from. This is where the derived number becomes the module's own.
jq --arg v "$VERSION" '.version = $v' module.json > "$OUT/module.json"
# The OpenAPI fragment, and the licence the code is under — a bundle
# shipping GPL code without its licence is not distributable.
cp swagger-fragment.json LICENSE.md README.md "$OUT/"
# The server half, minus everything that never runs inside core's
# process: no `test/`, no `scripts/`, no `swagger/`.
#
# An EXCLUSION list, not an include list, and that is the whole point.
# This was `for d in boot.js core.js index.js db model router`, which
# meant adding `server/utils/` — an ordinary thing to do — silently
# dropped it from every release: the bundle check below only resolves
# the five paths module.json names, so nothing failed here, and the
# module died on an operator's box as a `startup_failed` row instead
# (docs/modules/kit-acceptance.md, F4). Excluding is the safe default
# because the failure mode inverts: forget to exclude something and you
# ship a harmless extra file, rather than omitting a required one.
mkdir -p "$OUT/server"
for d in boot.js core.js index.js db model router; do
cp -r "server/$d" "$OUT/server/"
for e in server/*; do
case "$(basename "$e")" in
test|scripts|swagger|package-lock.json) continue ;;
esac
cp -r "$e" "$OUT/server/"
done
cp server/package.json "$OUT/server/"
[ -d server/node_modules ] && cp -r server/node_modules "$OUT/server/" || true
# The client half is the BUILT chunk only.
mkdir -p "$OUT/client/dist"
@@ -152,14 +327,18 @@ jobs:
# `startup_failed` row, instead of here.
node -e '
const fs = require("fs"), path = require("path");
const root = process.argv[1];
const [root, want] = process.argv.slice(1);
const m = JSON.parse(fs.readFileSync(path.join(root, "module.json"), "utf8"));
if (m.version !== want) {
console.error(`bundle declares ${m.version}, but this is release ${want}`);
process.exit(1);
}
for (const p of [m.server, m.schema, m.purge, m.client && m.client.entry, "swagger-fragment.json"]) {
if (!p) continue;
if (!fs.existsSync(path.join(root, p))) { console.error("bundle is missing " + p); process.exit(1); }
}
console.log("bundle contents check: ok");
' "$OUT"
' "$OUT" "$VERSION"
tar -C dist -czf "dist/${ID}-${VERSION}.tar.gz" "${ID}-${VERSION}"
rm -rf "$OUT"
@@ -185,39 +364,6 @@ jobs:
echo "${SHA} ${ID}-${VERSION}.tar.gz" > dist/SHA256SUMS
cat "dist/${ID}-${VERSION}.json"
- name: Write the changelog
if: ${{ steps.plan.outputs.release == 'true' }}
run: |
set -euo pipefail
ID="${{ steps.plan.outputs.id }}"
VERSION="${{ steps.plan.outputs.version }}"
LAST_TAG="$(git describe --tags --match 'v*' --abbrev=0 2>/dev/null || true)"
RANGE="${LAST_TAG:+${LAST_TAG}..}HEAD"
{
echo "## ${ID} v${VERSION}"
echo
echo "Install from the website's Admin → Modules screen by pasting the URL of"
echo "\`${ID}-${VERSION}.json\`, or unpack the tarball onto the modules volume as"
echo "\`modules/${ID}/\`. Requires a core whose \`MODULE_API_VERSION\` satisfies"
echo "\`$(node -p "require('./module.json').coreApi")\`."
echo
echo "The website only installs from hosts on its \`MODULE_SOURCE_HOSTS\` allowlist —"
echo "an operator installing this needs \`github.com\` on theirs."
echo
echo "### Changes"
if [ -n "$LAST_TAG" ]; then echo "Since ${LAST_TAG}:"; fi
git log --no-merges --format='- %s' $RANGE || true
echo
echo "### Verifying this download"
echo
echo "Releases are **unsigned** — the \`sha256\` in \`${ID}-${VERSION}.json\` is the"
echo "trust anchor, and the website verifies it before unpacking."
echo
echo '```bash'
echo "sha256sum -c SHA256SUMS --ignore-missing"
echo '```'
} > dist/CHANGELOG.md
- name: Tag and publish
if: ${{ steps.plan.outputs.release == 'true' }}
env:
@@ -228,10 +374,14 @@ jobs:
TAG="${{ steps.plan.outputs.tag }}"
VERSION="${{ steps.plan.outputs.version }}"
git config user.name 'github-actions[bot]'
git config user.email 'github-actions[bot]@users.noreply.github.com'
git tag -a "$TAG" -m "${ID} ${TAG}"
git push origin "$TAG"
# Skipped on a recovery run — the tag is already there, and is the
# thing being published against.
if [ "${{ steps.plan.outputs.reuse_tag }}" != "true" ]; then
git config user.name 'github-actions[bot]'
git config user.email 'github-actions[bot]@users.noreply.github.com'
git tag -a "$TAG" -m "${ID} ${TAG}"
git push origin "$TAG"
fi
gh release create "$TAG" \
--title "$TAG" \

36
template/.gitignore vendored Normal file
View File

@@ -0,0 +1,36 @@
# The template's own ignore rules, so they travel with a copy.
#
# The kit repo's root .gitignore covers these paths too, but that file stays
# behind: copy `template/` out, `git init`, and you inherit nothing — `node_modules/`
# included. Reported by the acceptance run (docs/modules/kit-acceptance.md, F6).
# dependencies
node_modules/
# ── The built client chunk ────────────────────────────────────────────────
#
# Ignored HERE and shipped in the RELEASE, which is not a contradiction: a module
# is installed prebuilt (an operator never builds anything), but the artifact is
# built by CI from the source next to it, and a chunk committed by hand goes stale
# beside fresh source without anything saying so.
#
# Your release workflow builds it before packing the bundle. If you would rather
# commit it, delete this line and accept that you now have to remember.
client/dist/
# env / secrets — a module's env vars are the operator's, never the repo's
.env
*.env
!.env.example
# release staging, produced by .gitea/workflows/release.yml
/dist/
*.tar.gz
# logs / os / editor
*.log
npm-debug.log*
.DS_Store
Thumbs.db
.vscode/
.idea/

View File

@@ -5,11 +5,16 @@ rename it, and you have a running module before you have read a chapter.
Installed into a core, it adds:
- **one public page** at `/examplegame/status`, and a nav row pointing at it;
- **one API route**, `GET /api/v1/public/world/status`, described in an OpenAPI
fragment core merges into its own `/api/docs`;
- **one table**, `examplegame_world_status`, created by an idempotent schema
- **three public pages** — `/examplegame/status`, `/examplegame/clans` and one
clan at `/examplegame/clans/:externalId` — and nav rows pointing at the first two;
- **three API routes** under `/api/v1/public/world` and `/api/v1/public/clans`,
described in an OpenAPI fragment core merges into its own `/api/docs`;
- **three tables**, prefixed `examplegame_`, created by an idempotent schema
fragment and dropped by a purge file;
- **a Team provider**, which makes this module the authoritative source of Teams
for the deployment — the one registration where core calls YOU and waits;
- **three inverted extension slots**, declared by this module on the clan page for
core to fill;
- **both lifecycle hooks**, so there is something to see at boot and at shutdown.
That is deliberately less than your module will do. What it is *complete* about is
@@ -27,17 +32,18 @@ server/
db/schema.sql idempotent, replayed every boot
db/purge.sql destructive, run only by an explicit admin purge
model/worldStatus/ the .db.js / .model.js pair
router/public/ one router, one controller, the #swagger annotations
model/clans/ the Team provider, its SQL, and the audience rule
router/public/ two routers, two controllers, the #swagger annotations
swagger/doc.js tags and schemas the annotations refer to
scripts/checkImports.js the module boundary, enforced
scripts/swaggerFragment.js generates swagger-fragment.json from your own routes
test/ the suites — start with entry.test.js
client/
vite.config.js the library build: anchored aliases, external: []
src/entry.jsx registers routes and nav at evaluation time
src/core.js what core hands you: the seven-member UI kit
src/entry.jsx registers routes, nav and declared slots at evaluation time
src/core.js what core hands you: the UI kit (nine exports)
src/shim/ the four shared dependencies, re-exported from core
src/routes/public/ the page
src/routes/public/ the pages — Clan.jsx is the one with slots in it
scripts/checkExternals.js asks the BUILT chunk whether a bare import survived
test/ build.test.js and registration.test.js
.gitea/workflows/release.yml packaging CI — Gitea
@@ -103,23 +109,32 @@ backticking table names**.
| File | What to change |
| --- | --- |
| `module.json` | `id`, `name`, `version`, the `mounts` prefix, `capabilities` |
| `.gitea/workflows/release.yml` | `GITEA_HOST` and `REPO`, under the `# CHANGE THESE` banner — the only two, and they are wrong until you do. (The `.github/` flavour needs nothing: GitHub supplies `GITHUB_REPOSITORY` and friends.) |
| `server/package.json` | package `name` and `description` |
| `server/core.js` | the message every accessor throws |
| `server/index.js` | the trigger, audience, template and rule-group ids — all four are namespaced with your module id, and core refuses them otherwise |
| `server/boot.js` | the placeholder world name |
| `server/db/schema.sql` | every table name — the prefix must be your id |
| `server/db/purge.sql` | the same table names |
| `server/model/worldStatus/worldStatus.db.js` | the `TABLE` constant |
| `server/model/clans/clanProvider.db.js` | the `CLANS` and `MEMBERS` table constants |
| `server/model/clans/clanProvider.model.js` | `pageUrlTemplate` — it must match the route `client/src/entry.jsx` registers |
| `server/router/public/world.router.js` | the `#swagger.tags` name |
| `server/router/public/clans.router.js` | the `#swagger.tags` name |
| `server/swagger/doc.js` | the tag, and the `Examplegame…` schema prefix |
| `server/scripts/swaggerFragment.js` | the generated fragment's `info.title` |
| `server/test/_fakes.js` | `ctx.moduleId` |
| `server/test/entry.test.js` | the trigger id the world-status test asserts |
| `server/test/worldStatus.test.js` | the fixture's world name |
| `server/test/clanProvider.test.js` | the fixture's world name |
| `server/package-lock.json` | **regenerated** — `npm install --prefix server` |
| `client/package.json` | package `name` and `description` |
| `client/vite.config.js` | the guard plugin's `name` |
| `client/src/core.js` | the console tag on the identity check |
| `client/src/shim/rg.js` | the console tag on the missing-global error |
| `client/src/entry.jsx` | `ID`, and every route path and nav `to` |
| `client/src/entry.jsx` | `ID`, every route path and nav `to`, and the three `declareModuleSlot` names — core enforces that a slot is namespaced under your id |
| `client/src/routes/public/Clans.jsx` | the link to the clan page |
| `client/src/routes/public/Clan.jsx` | the three `<Slot name>` values and their `moduleId` |
| `client/test/registration.test.js` | the example path in the comment |
| `client/package-lock.json` | **regenerated** — `npm install --prefix client` |
| `swagger-fragment.json` | **regenerated** — `npm run swagger --prefix server` |
@@ -132,11 +147,14 @@ placeholder and is not listed fails the build, and so does a listed file with
nothing left to rename. A checklist nobody verifies is a checklist that is wrong
by the second edit.
Two things you do **not** rename: the mount prefix `/world` need not be your id
(the server's prefix namespace is shared with core's, and `/status`, `/settings`,
`/version` and `/contact` are already taken), and the `world` / `worldStatus`
Two things you do **not** rename: the mount prefixes `/world` and `/clans` need
not be your id (the server's prefix namespace is shared with core's — `/status`,
`/settings`, `/version`, `/contact` and `/teams` are already taken, which is why
the clan router is not mounted at the obvious name), and the `world` / `clan`
naming throughout is ordinary vocabulary you should replace with your own domain's
when you replace the feature.
when you replace the feature. **`clan` in particular is the point rather than the
placeholder:** core's word is Team, yours is whatever your game says, and the
provider exists because core cannot pick one.
## Licence

View File

@@ -27,8 +27,16 @@ export const world = {
status: () => req('/public/world/status'),
}
// The module's own clan surface. Core serves its own view of the same things as
// Teams, at `/public/teams` — which is why the prefix here is `/clans` and could
// not be `/teams`; see `server/router/public/clans.router.js`.
export const clans = {
list: () => req('/public/clans'),
get: (externalId) => req(`/public/clans/${encodeURIComponent(externalId)}`),
}
// 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 { world, BASE }
export default { world, clans, BASE }

View File

@@ -45,14 +45,21 @@ if (createElement !== rg.react.createElement || createRoot !== rg.reactDom.creat
)
}
// The curated kit (§3.4). Seven members, and it is CLOSED: layout, headings, the
// three data-page states, the fetch hook, and read-only access to the session and
// the site's settings. Anything else your pages need — tables, tabs, an editor —
// you bundle yourself, in a `components/` directory of your own.
// 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 a
// kit component's props is a major one. Use them, though. A module page that
// 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 {
@@ -64,6 +71,7 @@ export const {
useAsync,
useAuth,
useSite,
Slot,
} = rg.ui
// The registry, for entry.jsx. Everything else here is read by pages.

View File

@@ -19,6 +19,8 @@
import { registry, coreApiVersion } from './core.js'
import WorldStatus from './routes/public/WorldStatus.jsx'
import Clans from './routes/public/Clans.jsx'
import Clan from './routes/public/Clan.jsx'
// Your module id, exactly as `module.json` spells it. Core keys the registry by
// it and prefixes every route path with it.
@@ -42,6 +44,13 @@ const ID = 'examplegame'
registry.registerRoutes(ID, {
public: [
{ path: 'status', element: <WorldStatus /> },
{ path: 'clans', element: <Clans /> },
// A parameter, and the name matters twice: `useParams()` in the page reads
// `externalId`, and the server's `pageUrlTemplate` substitutes `{externalId}`
// into this same path so core's notification email can link here. Nothing
// checks those three against each other — this is the seam to get right by
// hand, and the cost of getting it wrong is mail linking at a page that 404s.
{ path: 'clans/:externalId', element: <Clan /> },
],
})
@@ -68,9 +77,58 @@ registry.registerNav(ID, {
area: 'public',
items: [
{ label: 'World', to: '/examplegame/status' },
// The clan PAGE gets no nav row: rows point at pages a visitor can reach
// without knowing an id, and `/examplegame/clans/:externalId` is not one.
// `registration.test.js` checks every row against a route this module
// registered, which is the agreement that rots quietly.
{ label: 'Clans', to: '/examplegame/clans' },
],
})
// ── The inverted slot: this module DECLARES, core fills ───────────────────
//
// Everywhere else, core declares a place and a module fills it
// (`registry.registerExtension`). This is the mirror, added in MODULE_API 1.6.0
// for Teams: **a module declares a place on its own page and core fills it.**
//
// Teams are a core primitive with no core surface — core owns the tables, the
// membership sync, the access rules, the forum and the feed, and does not own the
// word "clan" — so the page is this module's and core contributes into it.
//
// Each declaration says two things: WHERE, in this module's own vocabulary, and
// WHICH of core's contributions belongs there. **Core offers a contribution and
// never names a slot** — it cannot, since it does not know what you called your
// page — so the second argument is the whole of what gets core's content onto it.
// Core's three, as of 1.6.0:
//
// `team.activity` the Team activity feed
// `team.forum` the Team forum panel
// `team.notify` the per-Team notification control
//
// Four things about these three lines:
//
// • **The name must be namespaced under this module's id**, and core enforces
// that rather than trusting it. It is what keeps two modules from claiming one
// name, and it makes the owner readable at the point of use in `Clan.jsx`.
// • **One slot per PLACE, not one per page.** A slot holds one component, so
// three contributions need three declarations — and this module then decides
// where each one sits, which is the freedom it declared them for.
// • **Asking for a contribution core does not offer THROWS here**, unlike almost
// everything else in the registry, which fails open. Core's catalogue is fixed
// at build time and your `coreApi` range has already been checked, so an
// unknown one is always a typo or a version skew — and the alternative failure
// is a page that renders empty forever with nothing logged.
// • **`{ core }` is optional.** A slot that asks for nothing stays empty, which
// is what you want for a place you intend to fill yourself.
//
// Declaring costs nothing on a core that offers none of them: core's fills are
// applied after every module chunk has evaluated, and a contribution nothing asks
// for is a no-op rather than an error. Both directions of that are silent on
// purpose — neither side may assume the other is there.
registry.declareModuleSlot(ID, 'examplegame.clan.header', { core: 'team.notify' })
registry.declareModuleSlot(ID, 'examplegame.clan.detail', { core: 'team.activity' })
registry.declareModuleSlot(ID, 'examplegame.clan.forum', { core: 'team.forum' })
// `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 your manifest and the core that published this

View File

@@ -0,0 +1,113 @@
// ── One clan — and the page that inverts the extension-slot direction ─────
//
// Everywhere else in this template, core owns a page and this module contributes
// to it. Here it is the other way round: **this module owns the page and core
// contributes to it**, through slots this module declared in `entry.jsx`.
//
// **Why it has to be this way round.** A Team is a core primitive — core owns the
// tables, the membership sync, the access rules, the forum and the activity feed
// — but core has no word for one. This game says clan, the next will say company,
// and a core-rendered `/teams` page would publish a noun core invented, beside
// this module's own page for the same thing. So the page is the module's, and the
// parts core cannot hand over are contributed into it.
//
// What core cannot hand over is worth being concrete about, because it is the
// test for whether something belongs in a slot: the activity feed's public/members
// split can only be resolved by the thing that owns membership, which is core.
// This module could render a feed; it could not decide who sees which half of it.
//
// **Three properties of `Slot` to know before you use one:**
//
// • It renders NOTHING when nothing fills it. A core that knows no Teams, a
// deployment with the forum switched off, a viewer with no membership — all
// of them are an empty slot and none of them is an error. Design the page to
// read correctly with every slot empty, because on some deployment it will.
// • **First fill wins**, and this module could fill its own declared slot. It
// does not, and that is the point of declaring one — but the rule is there so
// that a module can override core's contribution on a page it owns.
// • `externalId` is what core resolves the Team from, in THIS module's terms.
// Core maps its own Team from `(moduleId, externalId)`; the module never
// learns core's Team id and does not need to.
import { useParams, Link } from 'react-router-dom'
import { ErrorState, Loading, PageHeader, PublicLayout, Slot, useAsync } from '../../core.js'
import api from '../../api.js'
export default function Clan() {
const { externalId } = useParams()
const { data, loading, error } = useAsync(() => api.clans.get(externalId), [externalId])
return (
<PublicLayout shell="narrow">
{loading && <Loading />}
{error && <ErrorState error={error} />}
{data && (
<>
<PageHeader
title={data.name}
lead={`${data.memberCount} members${data.abbr ? ` · ${data.abbr}` : ''}`}
/>
{/* Core's per-Team notification control lands here — ABOVE the roster,
deliberately. Muting a clan is an action ON this page, so it belongs
beside the heading rather than after the content. That placement is
this module's decision to make, and it is the whole reason for
declaring three slots rather than one: a single slot would hand core
the choice of where each of its contributions sits on a page core
does not own. */}
<Slot name="examplegame.clan.header" externalId={externalId} moduleId="examplegame" />
{data.members.length > 0 ? (
<table style={{ width: '100%', borderCollapse: 'collapse', marginTop: '1rem' }}>
<thead>
<tr style={{ textAlign: 'left', opacity: 0.7 }}>
<th style={{ padding: '0.4rem 0.5rem' }}>Name</th>
<th style={{ padding: '0.4rem 0.5rem' }}>Rank</th>
<th style={{ padding: '0.4rem 0.5rem' }}>Status</th>
</tr>
</thead>
<tbody>
{data.members.map((m) => (
<tr key={`${m.displayName}-${m.rankLabel}`}>
<td style={{ padding: '0.4rem 0.5rem' }}>
{m.displayName}{m.leader ? ' ★' : ''}
</td>
<td style={{ padding: '0.4rem 0.5rem' }}>{m.rankLabel || '—'}</td>
<td style={{ padding: '0.4rem 0.5rem' }}>{m.online ? 'online' : 'offline'}</td>
</tr>
))}
</tbody>
</table>
) : (
// Three quite different things produce an empty roster, and the server
// says which: a clan with nobody in it, an audience rule that excludes
// this viewer, and a rule nobody could resolve. A page that cannot tell
// them apart reports the last as the first.
<p style={{ opacity: 0.7, marginTop: '1rem' }}>
{data.projected
? 'No roster has been reported for this clan yet.'
: 'The roster is not available to you right now.'}
</p>
)}
{/* Core's Team activity feed. It is core's because only core can
resolve the public/members split on it — this module owns who is in
the clan, core owns what being in one entitles you to see. */}
<Slot name="examplegame.clan.detail" externalId={externalId} moduleId="examplegame" />
{/* And core's Team forum, in its own place below the feed. Core resolves
who may read and post; this module renders the room and never its
door policy. Empty on a deployment with forums switched off, which is
the default. */}
<Slot name="examplegame.clan.forum" externalId={externalId} moduleId="examplegame" />
<p style={{ marginTop: '1.5rem' }}>
<Link to="/examplegame/clans">← All clans</Link>
</p>
</>
)}
</PublicLayout>
)
}

View File

@@ -0,0 +1,52 @@
// ── The clan list ─────────────────────────────────────────────────────────
//
// An ordinary index page, here mostly so the clan page below it has somewhere to
// be linked from. The interesting file is `Clan.jsx`.
//
// `Link` comes from `react-router-dom`, which resolves through this module's shim
// to core's router — so a click navigates inside the SPA rather than reloading
// the site. An `<a href>` here would work and would cost a full page load and the
// session-shaped flash that comes with it.
import { Link } from 'react-router-dom'
import { EmptyState, ErrorState, Loading, PageHeader, PublicLayout, useAsync } from '../../core.js'
import api from '../../api.js'
export default function Clans() {
const { data, loading, error } = useAsync(() => api.clans.list(), [])
return (
<PublicLayout shell="narrow">
<PageHeader title="Clans" lead="The companies, orders and warbands of the world" />
{loading && <Loading />}
{error && <ErrorState error={error} />}
{data && data.clans.length === 0 && (
<EmptyState message="No clans have been reported yet." />
)}
{data && data.clans.length > 0 && (
<ul style={{ listStyle: 'none', padding: 0, display: 'grid', gap: '0.5rem' }}>
{data.clans.map((clan) => (
<li key={clan.externalId}>
<Link to={`/examplegame/clans/${clan.externalId}`}>
{clan.name}{clan.abbr ? ` [${clan.abbr}]` : ''}
</Link>
<span style={{ opacity: 0.7 }}>
{' '}— {clan.memberCount} member{clan.memberCount === 1 ? '' : 's'}
</span>
</li>
))}
</ul>
)}
{data && data.stale && (
<p style={{ opacity: 0.7, marginTop: '1rem' }}>
The game has not reported recently, so this list may be out of date.
</p>
)}
</PublicLayout>
)
}

View File

@@ -3,13 +3,24 @@
// 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 seven-member kit).
// not) and where the chrome comes from (`../../core.js`, the shared UI kit).
//
// **Render `PublicLayout` yourself.** Core wraps your 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 in their layouts for you.
//
// **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. Without it your content starts hard against the
// left edge of the window and the footer rides up underneath it, which reads as
// a CSS bug in your module and is not one. Widths are 'narrow', 'mid' and
// 'wide'; name a width, never a class, because the classes belong to core's
// stylesheet and it is free to rename them (§3.4, MODULE_API_VERSION 1.5.0).
//
// This is here because the kit's acceptance run got it wrong by following the
// kit: a module built to the letter of chapter 2 rendered outside the site.
import { ErrorState, Loading, PageHeader, PublicLayout, useAsync } from '../../core.js'
import api from '../../api.js'
@@ -34,10 +45,15 @@ export default function WorldStatus() {
const { data, loading, error } = useAsync(() => api.world.status(), [])
return (
<PublicLayout>
<PublicLayout shell="narrow">
<PageHeader
title="World status"
subtitle="What the game server last told us about itself"
// `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. Nothing warns.
// Found by installing this template into a real core and looking at it.
lead="What the game server last told us about itself"
/>
{loading && <Loading />}

View File

@@ -35,6 +35,12 @@ 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 })
@@ -44,6 +50,7 @@ function fakeRg() {
const nav = { public: [], admin: [], player: [] }
const providers = new Map()
const extensions = new Map()
const declaredSlots = []
return {
version: manifest.coreApi.replace(/^\D+/, ''),
react,
@@ -54,7 +61,7 @@ function fakeRg() {
// 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']
['PublicLayout', 'PageHeader', 'Loading', 'ErrorState', 'EmptyState', 'useAsync', 'useAuth', 'useSite', 'Slot']
.map((n) => [n, stub(n)]),
),
api: { request: async () => ({}), ApiError: Error, BASE: '/api/v1' },
@@ -72,10 +79,22 @@ function fakeRg() {
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 }),
_read: () => ({ routes, nav, providers, extensions, declaredSlots }),
}
}
@@ -166,12 +185,38 @@ it('every slot module.json declares is one the chunk fills', () => {
}
})
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.
const pages = fs.readFileSync(path.resolve(HERE, '..', 'src', 'routes', 'public', 'Clan.jsx'), 'utf8')
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])
})

View File

@@ -2,13 +2,13 @@
"id": "examplegame",
"name": "Example Game",
"version": "0.1.0",
"coreApi": "^1.4.0",
"coreApi": "^1.9.0",
"server": "server/index.js",
"client": { "entry": "client/dist/entry.js" },
"schema": "server/db/schema.sql",
"purge": "server/db/purge.sql",
"mounts": {
"public": ["/world"]
"public": ["/world", "/clans"]
},
"capabilities": ["world-status"]
"capabilities": ["world-status", "clans"]
}

View File

@@ -30,6 +30,7 @@
const core = require('./core')
const worldStatusDb = require('./model/worldStatus/worldStatus.db')
const clanDb = require('./model/clans/clanProvider.db')
const log = core.logger('boot')
@@ -51,12 +52,86 @@ async function refresh() {
try {
// A real module calls its sidecar's REST API here. Two hardcoded values
// stand in, so that the page renders and the seam is visible.
await worldStatusDb.setStatus({ online: true, players: 0, worldName: 'Example World' })
const next = { online: true, players: 0, worldName: 'Example World' }
// ── Emitting a declared event ──────────────────────────────────────────
//
// **Emit on the TRANSITION, not on the poll.** This function runs every
// thirty seconds; a rule on an event fired every thirty seconds is a rule
// that mails somebody every thirty seconds. Core has a cooldown and an
// hourly cap and they would both hold, but leaning on them means the module
// is emitting "the world is still up" and calling it news. Read the previous
// state, compare, and emit only when the answer changed.
//
// The read is BEFORE the write for the same reason, and getting that
// backwards is the easy version of this bug: after `setStatus` the previous
// value is gone and every poll looks like no change at all — an emitter that
// never fires and never errors.
const previous = await worldStatusDb.getStatus()
await worldStatusDb.setStatus(next)
// `previous === null` is the first boot on a fresh install, not a change.
// Treating it as one would announce the world coming online to everyone the
// first time an operator started the site.
if (previous && Boolean(previous.online) !== next.online) {
// Fire-and-forget: no await, no return value, nothing to handle. Core
// validates the payload against what `index.js` declared, and what a
// mismatch does depends on where you are running. **In production it is
// dropped and logged** against this module, because a notification must
// never be able to break the thing it is about. **Anywhere else it throws**,
// at this line, so the stack points at your own call instead of at a
// warning nobody reads. Neither is a condition to catch: a payload that
// does not match the contract you declared is a bug to fix.
core.emit('examplegame.world.status_changed', {
data: {
worldName: next.worldName,
status: next.online ? 'online' : 'offline',
players: next.players,
url: '/world',
},
})
}
} catch (err) {
log.warn('could not refresh world status', { error: err.message })
}
}
/**
* Two clans, so that the Team provider has something to be authoritative about.
*
* A real module fills these tables from its sidecar — the roster arriving on its
* own frames, separately from the clan itself. That separation is why
* `member_count` is written from what the game SAYS the size is rather than from
* the rows: the provider needs both numbers to tell an empty clan from one whose
* roster has not landed, and a seeder that derives the count from its own array
* quietly removes the case the provider's most important guard exists for.
*
* **Core is not called here and does not have to be.** Registration is a claim;
* core reconciles on its own schedule, after `onBoot`, by calling the provider.
* A module that tried to push Teams into core would be a module racing core's
* reconciler for a table it does not own.
*/
async function seedClans() {
try {
await clanDb.replaceClan({
externalId: 'clan-1', name: 'The Gilded Company', abbr: 'GC', memberCount: 3,
members: [
{ memberKey: 'char-001', displayName: 'Aldric', rankLabel: 'Warlord', leader: true, online: true },
{ memberKey: 'char-002', displayName: 'Bryn', rankLabel: 'Member', online: false },
{ memberKey: 'char-003', displayName: 'Cass', rankLabel: 'Member', online: true },
],
})
await clanDb.replaceClan({
externalId: 'clan-2', name: 'Ash and Ember', abbr: 'A&E', memberCount: 1,
members: [
{ memberKey: 'char-101', displayName: 'Dael', rankLabel: 'Warlord', leader: true, online: false },
],
})
} catch (err) {
log.warn('could not seed clans', { error: err.message })
}
}
/**
* Runs once, after the schema and before the listener binds.
*
@@ -66,6 +141,7 @@ async function refresh() {
*/
async function onBoot() {
await refresh()
await seedClans()
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
@@ -89,4 +165,4 @@ async function onShutdown() {
log.info('shut down')
}
module.exports = { onBoot, onShutdown, refresh, REFRESH_MS }
module.exports = { onBoot, onShutdown, refresh, seedClans, REFRESH_MS }

View File

@@ -91,6 +91,21 @@ module.exports = {
// 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),
// 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.

View File

@@ -10,10 +10,11 @@
-- remove it — so core refuses to load a module that declares one without the
-- other.
--
-- **Drop in the reverse of creation order.** With one table it does not matter;
-- with a parent and its children it does, because dropping a parent first fails
-- on the constraint and a purge that fails halfway is worse than one that never
-- ran — it leaves exactly the orphaned data this file exists to remove.
-- **Drop in the reverse of creation order**, which this file now actually
-- depends on: `examplegame_clan_members` carries a foreign key into
-- `examplegame_clans`, so dropping the parent first fails on the constraint, and
-- a purge that fails halfway is worse than one that never ran — it leaves
-- exactly the orphaned data this file exists to remove.
-- `IF EXISTS` on every line, so a partially-installed module still tears down.
--
-- **What does NOT belong here: rows you wrote into core's tables.** Notification
@@ -21,4 +22,6 @@
-- a module does not DELETE from core's tables. Core prunes what it knows you
-- registered, because it is the one that knows which registrant owned what.
DROP TABLE IF EXISTS examplegame_clan_members;
DROP TABLE IF EXISTS examplegame_clans;
DROP TABLE IF EXISTS examplegame_world_status;

View File

@@ -68,3 +68,57 @@ CREATE TABLE IF NOT EXISTS examplegame_world_status (
-- again on every boot, and the second run must be a no-op rather than a
-- duplicate-key error that fails the whole replay.
INSERT IGNORE INTO examplegame_world_status (id, online, players) VALUES (1, 0, 0);
-- ── Clans, and who is in them ─────────────────────────────────────────────
-- The module's half of Teams (MODULE_API.md — `api.registerTeamProvider`, and
-- TEAMS.md §2.3). A **Team** is core's word and a core table; a **clan** is this
-- game's word for the same thing, and these two tables are what the module knows
-- about them. Core never reads either — it asks the provider in
-- `model/clans/clanProvider.model.js`, which reads these.
--
-- **That separation is the point of the whole primitive, and it is worth being
-- concrete about.** Core owns `teams`, `team_members`, the reconciler that syncs
-- them, the access rules, the forum and the activity feed. This module owns what
-- a clan IS, which members exist, and who may look. Nothing here is prefixed
-- `team_` because nothing here is core's; §2.6's prefix rule would refuse it
-- anyway, and the rule is doing real work in this direction — a module that
-- wrote into `team_members` would be a module racing core's reconciler.
--
-- In a real module both tables are filled by your sidecar ingest. Here `boot.js`
-- seeds two clans so the pages render and the seam is visible.
CREATE TABLE IF NOT EXISTS examplegame_clans (
external_id VARCHAR(64) NOT NULL PRIMARY KEY,
name VARCHAR(120) NOT NULL,
abbr VARCHAR(16) NULL,
-- What the game says the clan's roster size is, which is NOT the number of
-- rows next door. The two arrive separately in every real ingest, and the
-- provider needs both to tell "this clan is empty" from "its roster has not
-- landed yet" — the distinction that decides whether it answers or refuses.
member_count INT UNSIGNED NOT NULL DEFAULT 0,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
-- One row per character in a clan.
--
-- `member_key` is the game's own stable id for a character — a serial, a UUID,
-- whatever your game keeps — and it is what core stores as the member's identity.
-- It must survive a rename, because core reads a changed name as a rename and a
-- changed key as a different person.
--
-- `user_id` is the site account behind that character, resolved **by this
-- module**: the game↔site link table is yours, and a core that resolved it would
-- be core reading a module's table by name. NULL is the ordinary case — most
-- characters are not linked to an account.
CREATE TABLE IF NOT EXISTS examplegame_clan_members (
clan_id VARCHAR(64) NOT NULL,
member_key VARCHAR(64) NOT NULL,
display_name VARCHAR(120) NULL,
rank_label VARCHAR(60) NULL,
is_leader TINYINT(1) NOT NULL DEFAULT 0,
is_online TINYINT(1) NOT NULL DEFAULT 0,
user_id INT UNSIGNED NULL,
PRIMARY KEY (clan_id, member_key),
CONSTRAINT fk_examplegame_clan_members_clan
FOREIGN KEY (clan_id) REFERENCES examplegame_clans (external_id) ON DELETE CASCADE
);

View File

@@ -48,6 +48,8 @@ module.exports = function register(ctx, api) {
/* eslint-disable global-require */
const worldRouter = require('./router/public/world.router')
const clansRouter = require('./router/public/clans.router')
const clanProvider = require('./model/clans/clanProvider.model')
const boot = require('./boot')
/* eslint-enable global-require */
@@ -74,7 +76,190 @@ module.exports = function register(ctx, api) {
// is exactly the one that would have gone wrong. Check the list before you
// choose (§2.4, and MODULE_SYSTEM.md §2.7's own note about the probe).
api.registerRoutes({
public: { '/world': worldRouter },
public: { '/world': worldRouter, '/clans': clansRouter },
})
// ── Teams: this module is the authoritative source of them ───────────────
//
// A Team is a CORE entity — core owns the tables, the reconciler, the access
// rules, the forum and the activity feed. What core does not own is the word for
// one, because this game says clan and the next will say company. So core asks
// this module three questions and never reads its tables (MODULE_API 1.6.0).
//
// **This is the first registration where core calls YOU and waits**, which is
// what makes it unlike every other line in this file: the others hand core a
// router to mount or a row to draw. Two consequences worth carrying:
//
// • **Registration is a claim, not a call.** Nothing in the provider runs
// until core reconciles, which is after `onBoot` — which is what makes it
// legal for every one of its methods to read the database while this
// function may not (§2.2).
// • **One provider per deployment.** Unlike every other registry this holds a
// single value: two modules answering "what Teams exist" would produce two
// disjoint sets under one table with no rule for merging them. A second
// registration is a collision, reported against the module that holds it.
//
// The whole object is passed rather than picking its members out, so adding the
// optional ones is an edit to the provider and not to this file.
api.registerTeamProvider(clanProvider)
// ── Engagement: declaring what your game can announce ────────────────────
//
// The three calls below are one seam, and it is the one where a module is most
// tempted to reach past the boundary. **You declare what CAN happen; core
// decides who is told.** A module never names a person, a channel or an
// address, and never sends anything (MODULE_API 1.7.0 and 1.9.0; §2.7).
//
// A TRIGGER is not a notification stream, and the two are easy to confuse
// because both are catalogs of things that happen. A stream is a subscribe
// toggle you publish to yourself. A trigger is a PAYLOAD CONTRACT an operator
// writes rules against — it says what variables the event carries and how wide
// an audience it may ever be given, and core does the sending. Their ids share
// one namespace, so declaring both for one id is legal and is one event with a
// toggle and a contract; taking an id another module owns is not.
api.registerEventTriggers([
{
id: 'examplegame.world.status_changed',
label: 'World came up or went down',
description: 'The game server changed between online and offline.',
kind: 'event',
// The cooldown subject: "once per world", not "once per user". It must
// NAME one of the variables below — core refuses the registration
// otherwise, with this trigger's id in the message, and the module does not
// load. That check exists because the failure it prevents is silent: a
// subjectKey naming nothing keys every subject on `undefined`, which looks
// exactly like the feature working right up until two worlds share it.
subjectKey: 'worldName',
audience: 'authenticated', // what a rule is CREATED with
// ...and the widest it may EVER be given. Required, with no default,
// because there is no safe value to guess: `owner` would silently break a
// broadcast and `authenticated` would silently widen a staff-only event.
// The values are ordered by CONTAINMENT, not by size — see chapter 2.
ceiling: 'authenticated',
version: 1,
variables: [
// Every variable needs an `example`, and it is not decoration: it is what
// lets an operator preview and test-send a template without waiting for a
// real game event, which is the reason template systems ship untested.
{ name: 'worldName', type: 'string', required: true, example: 'Example World' },
{ name: 'status', type: 'string', required: true, example: 'online' },
{ name: 'players', type: 'int', required: false, example: 42 },
// A `url` is validated SITE-RELATIVE, because it ends up in an href in a
// mail somebody opens days later. Never a full URL of your own.
{ name: 'url', type: 'url', required: false, example: '/world' },
],
},
])
// An AUDIENCE is a named set of PEOPLE this module can resolve over its own
// data, for an operator to point a rule at. "This clan's members" is one;
// "everyone who opened the last mail" is not, and nothing here builds it.
//
// **The resolver returns user ids and nothing else.** It is not handed a
// template, a channel or an address and it cannot enumerate them — core maps
// ids to addresses on its own side, after preferences, suppression and the
// verification gate. That is what stops this becoming the back door §2.7 spends
// a section closing.
//
// Audiences are their OWN id space, unlike triggers and streams: an audience
// names a set of people and a trigger names an event, so the two may share a
// name without colliding.
api.registerAudiences([
{
id: 'examplegame.clan.members',
label: 'Members of a clan',
// `int` or `string` only, and CONSTANT — an operator fills these in when
// they save the rule. There is no way to say "the clan this event was
// about"; if a rule needs that, the EVENT carries its own recipients
// instead. Finding that out late is a phase's worth of rework.
params: [{ id: 'clanId', type: 'string', required: true }],
ceiling: 'members',
resolve: async ({ clanId }) => clanProvider.listClanMemberUserIds({ clanId }),
},
])
// Finally the CONTENT: the bodies your messages use, and the rules that decide
// when one is sent. Both arrive **switched off** — `enabled` is not a parameter
// and there is no call that sets it. An operator turns a module's mail on;
// installing a module never does.
//
// The two halves have different lifetimes, and the asymmetry is the contract:
//
// • **Templates are re-ensured on every boot**, under `seedVersion`, so
// improving a default body reaches deployments that never edited it — and
// one an operator HAS edited is marked customized and left alone. Bump
// `seedVersion` when the body changes; never for a comment.
// • **Rule groups are offered ONCE, per named group key.** Re-offering would
// resurrect a rule an operator deleted and reset one they enabled. So a rule
// appended to an existing group reaches FRESH INSTALLS ONLY. That is the
// guarantee rather than a limitation to work around: a rule that has to
// reach existing deployments takes a NEW group key, and you choose that
// knowingly because you name the groups.
//
// Core's generic bodies are a first-class answer, not a fallback: point a
// channel at `notify.event` / `inapp.event` / `notify.digest` and author
// nothing. Ship a body of your own when the message has something to say that a
// structural projection of the payload cannot. Below, the mail does — a world
// coming back deserves a sentence — and the in-app item does not, so it uses
// core's.
//
// Note the two casings, which are not a slip: a TEMPLATE is an object this call
// shapes (`triggerId`), and a RULE is a row (`trigger_id`). Copy them as they
// are.
api.registerEngagementSeeds({
templates: [
{
// MUST be namespaced `<moduleId>.` — the key column is unique across the
// whole table, and an unprefixed `notify.event` from a module would
// collide with core's own body and win.
key: 'examplegame.world-status-changed',
name: 'World — status changed',
channel: 'email',
subject: '{{worldName}} is {{status}}',
triggerId: 'examplegame.world.status_changed',
triggerVersion: 1,
seedVersion: 1,
// The same block objects the template editor writes, so an operator can
// open this in the admin panel and keep editing from here.
//
// **This is the one thing in this file core does not check for you.**
// `registerEngagementSeeds` asserts that `blocks` is a non-empty array and
// stops; the BODY is validated by the block registry, which runs in the
// editor and in the renderer. So a malformed block registers, seeds, and
// first shows itself when an operator opens the body or a rule fires.
// Two that are easy to get wrong: every block carries its own `id`, and
// `email.heading`'s `level` is 'h1' | 'h2' | 'h3' — not a number.
blocks: [
{ id: 'h', type: 'email.heading', props: { level: 'h2', text: '{{worldName}} is {{status}}' } },
{ id: 'intro', type: 'email.text', props: { text: 'There are {{players}} players online right now.' } },
{ id: 'cta', type: 'email.button', props: { label: 'Open the world page', url: '{{url}}' } },
],
},
],
ruleGroups: [
{
key: 'world-v1',
note: 'the world status rule, seeded once',
rules: [
{
trigger_id: 'examplegame.world.status_changed',
name: 'World status changes',
audience: 'authenticated',
channels: ['email', 'inapp'],
template_keys: {
email: 'examplegame.world-status-changed',
inapp: 'inapp.event',
},
// Two ceilings on volume, and they answer different questions. The
// cooldown is per SUBJECT — one mail per world per hour, however many
// times it flaps. The hourly cap is per RULE, and is the thing that
// keeps a misconfiguration from becoming a mail storm.
cooldown_seconds: 3600,
max_sends_per_hour: 200,
},
],
},
],
})
// The lifecycle hooks (§2.5). `onBoot` runs after core's schema, after this
@@ -93,6 +278,6 @@ module.exports = function register(ctx, api) {
log.info('registered', {
version: require('../module.json').version,
routes: 'public:/world',
routes: 'public:/world,/clans',
})
}

View File

@@ -0,0 +1,106 @@
// ── SQL for the clan tables ───────────────────────────────────────────────
//
// The same `.db.js` / `.model.js` split as `model/worldStatus/`, for the same
// reason: 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.
//
// Everything here reads this module's OWN tables. **Nothing in a module ever
// reads or writes `teams`, `team_members`, `team_forum_*` or any other core
// table** — core owns the Team, this module owns the clan, and the whole of the
// traffic between them is the provider next door answering three questions
// (MODULE_API.md §2.6's prefix rule, and §2.7).
const core = require('../../core')
const CLANS = 'examplegame_clans'
const MEMBERS = 'examplegame_clan_members'
/** Every clan the game has told us about. */
async function listClans() {
return core.query(
`SELECT external_id AS externalId, name, abbr, member_count AS memberCount
FROM ${CLANS}
ORDER BY name`,
)
}
/** One clan, or `undefined`. */
async function findClan(externalId) {
const rows = await core.query(
`SELECT external_id AS externalId, name, abbr, member_count AS memberCount
FROM ${CLANS}
WHERE external_id = ?`,
[externalId],
)
return rows[0]
}
/**
* One clan's roster.
*
* Ordered so that a page rendering it directly does not have to sort: leaders
* first, then by name. Ordering in SQL rather than in the model is a judgement
* call and this is the case for it — the database is doing it on an index, and
* the alternative is every caller remembering to.
*/
async function listMembers(clanId) {
return core.query(
`SELECT member_key AS memberKey, display_name AS displayName, rank_label AS rankLabel,
is_leader AS isLeader, is_online AS isOnline, user_id AS userId
FROM ${MEMBERS}
WHERE clan_id = ?
ORDER BY is_leader DESC, display_name`,
[clanId],
)
}
/**
* Replace what we know about one clan, in one transaction-shaped pair of writes.
*
* Called by whatever ingests from your sidecar; here, by `boot.js`. Delete-then-
* insert rather than an upsert, because a roster is a SET and the members who
* left are as much a part of the update as the ones who joined — an upsert leaves
* departed characters on the roster forever, and core would keep syncing them
* into a Team as present members.
*/
async function replaceClan({ externalId, name, abbr, memberCount, members }) {
await core.query(
`INSERT INTO ${CLANS} (external_id, name, abbr, member_count, updated_at)
VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP)
ON DUPLICATE KEY UPDATE name = VALUES(name), abbr = VALUES(abbr),
member_count = VALUES(member_count), updated_at = CURRENT_TIMESTAMP`,
[externalId, name, abbr || null, memberCount],
)
await core.query(`DELETE FROM ${MEMBERS} WHERE clan_id = ?`, [externalId])
for (const m of members) {
await core.query(
`INSERT INTO ${MEMBERS} (clan_id, member_key, display_name, rank_label, is_leader, is_online, user_id)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
[externalId, m.memberKey, m.displayName || null, m.rankLabel || null,
m.leader ? 1 : 0, m.online ? 1 : 0, m.userId || null],
)
}
}
/**
* The site accounts behind one clan's roster — the whole of an audience resolver.
*
* `user_id` is NULL for most characters, and the filter is the point: an audience
* resolves to PEOPLE WITH ACCOUNTS, and a character nobody has linked is not one.
* Returning its NULL would hand core a hole in an array it is about to mail.
*
* DISTINCT because one person may hold several characters in the same clan, and
* the resolver's contract is a set of users rather than a list of characters.
* Without it a three-character player is told three times.
*/
async function listMemberUserIds(clanId) {
const rows = await core.query(
`SELECT DISTINCT user_id AS userId
FROM ${MEMBERS}
WHERE clan_id = ? AND user_id IS NOT NULL`,
[clanId],
)
return rows.map((r) => r.userId)
}
module.exports = { listClans, findClan, listMembers, listMemberUserIds, replaceClan, CLANS, MEMBERS }

View File

@@ -0,0 +1,334 @@
// ── The Team provider ─────────────────────────────────────────────────────
//
// A **Team** is a core platform entity: core owns the tables, the reconciler that
// keeps them in step, the access rules, the forum and the activity feed. What
// core does not own is the word. This game calls them clans, the next will call
// them companies, and a core that picked one would be publishing a noun it
// invented. So core asks, and this file is the whole of the answer.
//
// Registered from `index.js` with `api.registerTeamProvider(...)` (MODULE_API 1.6.0).
//
// ── Why this registration is unlike every other one ───────────────────────
//
// It is the first place **core calls the module and waits**. `registerRoutes`
// hands core a router to mount, `registerNav` hands it a row to draw,
// `registerPostHook` asks to be told when something happens. This hands over
// something core will pick up and call — from its reconciler, and (for
// `projectRoster`) on a request path with someone waiting on the other end.
//
// That inversion is what every rule below follows from:
//
// • **Core's budget is 10 seconds** and it is core's, not yours. Past it the
// call is a refusal, whatever your function eventually returns.
// • **Every method returns an ENVELOPE, never a bare array.** A rejected
// promise, a synchronous throw, a timeout, a non-object, a missing `ok`, a
// malformed row — core reads every one of them as `{ ok: false }`. There is
// no shape a failure can take that core reads as "zero teams", which is the
// entire argument for the envelope: a bare array has exactly one such shape,
// `[]`, and it is the one a module returns while its sidecar is connecting.
// • **Refusing is normal.** `{ ok: false }` is an ordinary answer and not an
// error you failed to handle. Core keeps the projection it already has,
// records your reason and shows it to an operator. Nothing empties.
// • **`projectRoster` is the exception, and it fails CLOSED** — see it below.
//
// ── The one that is easy to get wrong ─────────────────────────────────────
//
// Answering `{ ok: true, teams: [] }` because the game is unreachable. It reads
// as "this deployment has no clans", which is an authoritative statement, and core
// acts on authoritative statements: it archives Teams that have stopped existing
// and departs members who have left. A cold start would empty every roster on the
// site, and the module would have done it by being helpful.
//
// So the guard is the first line of three of the four methods, and it is
// deliberately conservative: an unreachable game refuses, even though the tables
// below still hold a perfectly readable snapshot. Core cannot tell a snapshot
// five minutes old from one five days old, and it makes destructive decisions
// from a complete answer.
const core = require('../../core')
const db = require('./clanProvider.db')
const settings = require('./clanSettings')
const worldStatus = require('../worldStatus/worldStatus.model')
const log = core.logger('clans')
/** A refusal, in the shape core reads. */
const refuse = (reason) => ({ ok: false, reason })
/**
* Is what these tables hold current enough to answer with?
*
* The template has no sidecar, so it asks the freshness the rest of it already
* tracks: if nothing has reported in longer than the world-status window, the
* clan tables are a snapshot of unknown age. In a real module this is "is my
* sidecar socket connected", asked of the socket rather than of a status column —
* a process that has just started has not transitioned yet, so a persisted
* `connected` can be left over from the last run.
*/
async function gameIsReachable() {
const status = await worldStatus.getPublicStatus()
if (status.stale) return { ok: false, reason: 'the game has not reported recently; clan data may be stale' }
if (!status.online) return { ok: false, reason: 'the game is offline' }
return { ok: true }
}
/**
* `getTeams()` — every clan this deployment has.
*
* `externalId` is the game's own stable id, and choosing it is the one genuinely
* load-bearing decision in this file. **It must survive a rename**: core reads a
* known id with a new name as a rename and keeps the Team, its forum and its
* history; it reads an unknown id as a new Team and archives the old one. Handing
* over the clan's NAME as its id turns every rename into "the clan was deleted
* and a different one appeared", taking the forum with it.
*
* `meta` is an opaque object core stores and displays and never branches on. It
* is how a concept core has no word for — an alliance, a faction, a season —
* reaches a Team page without core acquiring an opinion about it.
*/
async function getTeams() {
const ready = await gameIsReachable()
if (!ready.ok) return refuse(ready.reason)
try {
const rows = await db.listClans()
return {
ok: true,
// `complete: true` says "this is every clan there is", which is what
// licenses core to archive the ones missing from it. A module that can only
// answer about some of them — a paged source, a partial cache — must leave
// it off, and core then adds and updates without ever archiving.
complete: true,
teams: rows.map((row) => ({
externalId: String(row.externalId),
name: row.name,
abbr: row.abbr || null,
meta: null,
})),
}
} catch (err) {
// The catch is not decoration. An unhandled rejection here would reach core's
// reconciler as a rejected promise, which it reads as a refusal anyway — but
// then nothing has logged your side of it, and the operator sees a Team sync
// that stopped with core blamed for it.
log.warn('getTeams failed', { message: err.message })
return refuse(`clan list unreadable: ${err.message}`)
}
}
/**
* `getTeamMembers(externalId)` — one clan's roster.
*
* **An empty roster is refused unless the game says the clan is empty.** The
* clan row and its members arrive on separate frames in any real ingest, so there
* is a window — a clan created seconds ago, a website that connected between the
* two — where core would otherwise be told authoritatively that a 40-member clan
* has nobody in it, and would depart all forty. `member_count` is what
* distinguishes "empty" from "not here yet", and it is the only thing that can:
* this is why the schema keeps a count the rows cannot supply.
*/
async function getTeamMembers(externalId) {
const ready = await gameIsReachable()
if (!ready.ok) return refuse(ready.reason)
try {
const clan = await db.findClan(externalId)
if (!clan) return refuse(`clan ${externalId} is unknown`)
const rows = await db.listMembers(externalId)
if (!rows.length && clan.memberCount > 0) {
return refuse(`roster for clan ${externalId} has not arrived yet (the game says ${clan.memberCount})`)
}
return {
ok: true,
complete: true,
members: rows.map((row) => ({
memberKey: row.memberKey,
displayName: row.displayName || null,
rankLabel: row.rankLabel || null,
leader: Boolean(row.isLeader),
online: Boolean(row.isOnline),
// Resolved by THIS module, from this module's own link table. Core does
// not resolve it and could not: the game↔site mapping is yours, and a
// core that read it would be core reading a module's table by name.
userId: row.userId || null,
})),
}
} catch (err) {
log.warn('getTeamMembers failed', { externalId, message: err.message })
return refuse(`roster unreadable: ${err.message}`)
}
}
/**
* `getTeamLeaders(externalId)` — every member who leads, by member key.
*
* **Plural, and answer it plurally.** Core treats multiple leaders as the normal
* case; a provider that can only name one is a provider whose deployment has one,
* not a shape core assumes. Leadership is what core grants forum moderation and
* Team-management rights from, so a leader missing here is a leader locked out of
* their own clan's forum.
*
* Keys, not rows: core already has the roster and only needs to know which of
* those keys lead. A key that is not in the roster is ignored rather than
* inventing a member.
*/
async function getTeamLeaders(externalId) {
const ready = await gameIsReachable()
if (!ready.ok) return refuse(ready.reason)
try {
const clan = await db.findClan(externalId)
if (!clan) return refuse(`clan ${externalId} is unknown`)
const rows = await db.listMembers(externalId)
return { ok: true, leaders: rows.filter((r) => r.isLeader).map((r) => r.memberKey) }
} catch (err) {
log.warn('getTeamLeaders failed', { externalId, message: err.message })
return refuse(`leadership unreadable: ${err.message}`)
}
}
/**
* May this viewer see this clan's roster? The audience model itself.
*
* **One rule, two callers**, and keeping it that way is the point of the split.
* `projectRoster` below answers the question for CORE's roster; the module's own
* `/public/clans/:externalId` route answers it for its own page. A second copy of
* the rule is a copy that drifts, and the drift is silent in the direction that
* matters — the page publishing what core is withholding.
*
* `viewer` is `{ userId, role }` or `null` for an anonymous caller. Core never
* hands over the `users` row, which would make every column of that table part of
* the contract.
*
* Throws rather than guessing when it cannot decide; both callers treat a throw
* as "withhold".
*/
async function rosterVisibleTo(externalId, viewer) {
const audience = await settings.getRosterAudience()
if (audience === 'public') return true
// **Anonymous is an ANSWER, not a failed lookup.** Core hands over `null` for a
// viewer with no session, and treating that as "I could not work out who this
// is" would refuse — serving an empty roster to every visitor on a deployment
// whose clans are public.
if (!viewer) return false
if (audience === 'staff') return viewer.role === 'admin' || viewer.role === 'moderator'
// `'members'`: someone whose account is behind a character in this clan.
// Resolved from this module's own roster, the only place that mapping exists.
const roster = await db.listMembers(externalId)
return roster.some((r) => r.userId && r.userId === viewer.userId)
}
/**
* `projectRoster(externalId, members, viewer)` — who may see this roster.
*
* Optional, and the only method core calls on a REQUEST path. Core holds the
* roster and its public shape; the question that is yours is *who is allowed to
* look*, because the audience model is yours and core does not have one.
*
* **This one fails CLOSED, and the asymmetry is the point.** For the other three,
* an unanswered call must change nothing — core keeps what it has. For this one,
* "keep what you have" means serving the roster unprojected to whoever asked,
* which is a leak. So core distinguishes two refusals, and you get the right one
* without doing anything:
*
* • **no provider, or no `projectRoster`** — there is no audience model to
* consult and nothing is being withheld, so core serves the roster whole at
* its own public shape. That is what makes this member genuinely optional:
* omit it and a deployment with no rungs of its own still renders.
* • **a `projectRoster` that refused, threw, timed out or answered malformed**
* — core serves an EMPTY roster and says so (`projected: false`,
* `projectionUnavailable: true`). You said you had an opinion and then did
* not give it.
*
* **Note what it does not gate on: whether the game is reachable.** Visibility is
* a question about this deployment's configuration, not about the game — and
* refusing here because a socket is down would blank a public roster every time
* the game restarted.
*
* **Withhold rows; do not strip fields.** Core's public roster shape already
* omits the member key and the site account id, so there is nothing here to
* redact. Return every key or none — and "every key or none" is the honest
* translation of an audience model that is a property of the FEATURE rather than
* of a member.
*/
async function projectRoster(externalId, members, viewer) {
try {
const visible = await rosterVisibleTo(externalId, viewer)
return { ok: true, members: visible ? members.map((m) => m.member_key) : [] }
} catch (err) {
// Refusing is what withholds the roster. The tempting alternative — return
// every key, because the lookup failed and the rows are right there —
// publishes a roster an operator may have gated to staff.
log.warn('projectRoster could not resolve visibility; withholding the roster', {
externalId, message: err.message,
})
return refuse(`visibility could not be resolved: ${err.message}`)
}
}
// Where core should point a link at a clan.
//
// **Data, not a method**, and the fifth member of the provider. Core cannot work
// this out for itself and is not supposed to: Teams have no core surface, so the
// module that owns the vocabulary owns the page, and the one thing core needs
// back is where that page lives. A notification email about a forum reply that
// cannot take you to the thread is most of the way to useless.
//
// Core substitutes `{externalId}` and `{slug}` and does nothing else with it. A
// **relative path only** — a template naming its own host is refused at
// registration, protocol-relative `//host/x` with it, because there is no reason
// for a module to redirect the site's outbound mail.
//
// It must match the route `client/src/entry.jsx` registers, and nothing checks
// that for you across the two halves. Omit the member and the deployment loses
// clickable links in Team notification email; omit the ROUTE and it gets links to
// a page that does not exist, which is worse.
const pageUrlTemplate = '/examplegame/clans/{externalId}'
// ── The audience resolver ─────────────────────────────────────────────────
//
// Registered in `index.js` as `examplegame.clan.members` and called by core when
// a rule pointed at that audience fires. It lives beside the provider because it
// answers a question about the same rows, and it is NOT part of the provider —
// core calls it through the audience registry, not through the five members
// above.
//
// **Three rules, and every one of them protects somebody's mailbox rather than
// this module's correctness.**
//
// 1. Return user ids and nothing else. You are not handed a template, a channel
// or an address, and you may not enumerate them; core maps ids to addresses
// on its own side, after preferences, suppression and the verification gate.
// 2. Never widen on failure. A resolver that cannot answer returns the EMPTY set
// — never "everyone", never the last good answer. Core treats a throw the
// same way, but doing it here is what lets the log say which clan.
// 3. It is a SET of people, not a list of characters. The `DISTINCT` is in the
// query for that reason (see `clanProvider.db.js`).
async function listClanMemberUserIds({ clanId }) {
try {
return await db.listMemberUserIds(clanId)
} catch (err) {
log.warn('could not resolve clan members; resolving to nobody', {
clanId, message: err.message,
})
return []
}
}
module.exports = {
getTeams,
getTeamMembers,
getTeamLeaders,
projectRoster,
rosterVisibleTo,
pageUrlTemplate,
gameIsReachable,
listClanMemberUserIds,
}

View File

@@ -0,0 +1,22 @@
// ── Who may see a roster ──────────────────────────────────────────────────
//
// The audience model, in its own file because it is its own thing: **core has no
// audience model at all**, does not know what your rungs are called, and cannot
// invent one — which is the entire reason `projectRoster` exists. A module that
// has no such model omits that method and core serves rosters whole; a module
// that has one answers with it.
//
// A constant here, and an async function returning it, because in a real module
// this reads an operator setting — whether rosters are public is a deployment's
// decision, not a module author's. Keeping the read behind a function is also
// what makes the rule testable: the provider's fail-closed path is only reachable
// if the audience lookup can fail, and a bare constant can never fail.
/** `'public'` · `'members'` (accounts behind a character in the clan) · `'staff'`. */
const ROSTER_AUDIENCE = 'public'
async function getRosterAudience() {
return ROSTER_AUDIENCE
}
module.exports = { getRosterAudience, ROSTER_AUDIENCE }

View File

@@ -0,0 +1,83 @@
// ── The module's own view of its clans ────────────────────────────────────
//
// What `/api/v1/public/clans` serves. Separate from `clanProvider.model.js`
// because the two answer to different consumers: the provider answers CORE, in
// core's vocabulary, under core's envelope contract; this answers this module's
// own page, in the game's vocabulary, under the ordinary rules of an HTTP route.
//
// **They share the audience rule and nothing else.** `rosterVisibleTo` lives in
// the provider and is imported here, because a second copy is a copy that drifts
// — and it drifts in the direction that matters, this page publishing a roster
// core is withholding.
const clanProvider = require('./clanProvider.model')
const db = require('./clanProvider.db')
/**
* Every clan, with its size and nothing else.
*
* **A list is not a sync, so this does not refuse.** The provider's guard exists
* because core makes destructive decisions from a complete answer; a page makes
* none. An unreachable game here means the list is as old as it is, and saying so
* is `stale` — the same shape `worldStatus` already answers with, for the same
* reason.
*/
async function listPublic() {
const [rows, reachable] = await Promise.all([db.listClans(), clanProvider.gameIsReachable()])
return {
stale: !reachable.ok,
clans: rows.map((row) => ({
externalId: String(row.externalId),
name: row.name,
abbr: row.abbr || null,
memberCount: Number(row.memberCount) || 0,
})),
}
}
/**
* One clan and its roster, or `null`.
*
* **What is deliberately not here: `memberKey` and `userId`.** Both are in the
* tables and both go to core on the provider's envelope, because core needs an
* identity to reconcile against and an account to notify. Neither belongs on a
* public page: the member key is the game's internal handle for a character, and
* the account id maps a character to a person. Core's own public roster withholds
* both whatever `projectRoster` answers — a module route that published them
* would route around its own visibility rules while looking like it respected
* them.
*/
async function getPublic(externalId, viewer = null) {
const clan = await db.findClan(externalId)
if (!clan) return null
let members = []
let projected = true
try {
if (await clanProvider.rosterVisibleTo(externalId, viewer)) {
members = (await db.listMembers(externalId)).map((row) => ({
displayName: row.displayName || null,
rankLabel: row.rankLabel || null,
leader: Boolean(row.isLeader),
online: Boolean(row.isOnline),
}))
}
} catch {
// Withhold, exactly as the provider does. `projected: false` says which of
// the three reasons an empty roster has — no members, an audience that
// excludes you, or a question nobody could answer — and a page that cannot
// tell them apart will report the last as the first.
projected = false
}
return {
externalId: String(clan.externalId),
name: clan.name,
abbr: clan.abbr || null,
memberCount: Number(clan.memberCount) || 0,
projected,
members,
}
}
module.exports = { listPublic, getPublic }

View File

@@ -0,0 +1,51 @@
// ── Public · Clans — the handlers ─────────────────────────────────────────
//
// Thin, like `world.controller.js`, and for the same reason: everything worth
// testing is in the model, which needs no express and no database to test.
//
// The one thing these two do differently is read the caller.
// `core.auth.getUserFromRequest` is READ-ONLY access to who is asking — minting a
// session is core's job, and a module that needs an identity needs to read one,
// never to issue one. It is awaited and it never throws for an anonymous caller;
// it answers `null`, which is an answer the model expects.
const core = require('../../core')
const clans = require('../../model/clans/clans.model')
const log = core.logger('clans')
/**
* The viewer core's contract describes: `{ userId, role }`, or `null`.
*
* Built here rather than passed as a request, so the model takes the same shape
* core hands `projectRoster` and one audience rule can serve both. Handing a
* model the whole `req` is what makes a rule impossible to reuse from a call that
* has no request — and the provider's call has none.
*/
async function viewerFrom(req) {
const user = await core.auth.getUserFromRequest(req)
return user ? { userId: user.id, role: user.role } : null
}
async function list(req, res) {
try {
res.json(await clans.listPublic())
} catch (err) {
log.error('failed to list clans', { error: err.message })
res.status(500).json({ error: 'Failed to list clans' })
}
}
async function detail(req, res) {
try {
const clan = await clans.getPublic(req.params.externalId, await viewerFrom(req))
if (!clan) return res.status(404).json({ error: 'No such clan' })
return res.json(clan)
} catch (err) {
log.error('failed to read clan', { externalId: req.params.externalId, error: err.message })
return res.status(500).json({ error: 'Failed to read clan' })
}
}
module.exports = { list, detail }

View File

@@ -0,0 +1,52 @@
// ── Public · Clans ────────────────────────────────────────────────────────
//
// Mounted at `/api/v1/public/clans`. The module's own surface for the things
// core calls Teams — the list and one clan's roster, in this game's vocabulary,
// served from this module's tables.
//
// ── Why the prefix is `/clans` and could not be `/teams` ──────────────────
//
// **Core mounts `/api/v1/public/teams` itself.** Teams are a core primitive, so
// core answers the platform-level questions about them; what this module adds is
// the same clans in its own words, with the fields core has no schema for. The
// loader would refuse `/teams` outright at registration — a prefix collision it
// CAN see, unlike the tier-root routes `world.router.js` warns about — so the
// failure here is loud, immediate, and a boot that never happens.
//
// Which raises the question worth answering before you copy this: **does your
// module need this router at all?** Core already serves `/public/teams` and
// `/public/teams/:slug/roster`, projected through your `projectRoster`. A module
// wants its own only when it has something core does not model — here the game's
// rank labels and who is online, which are this game's ideas and not Teams. If
// what you would serve is what core already serves, do not.
const core = require('../../core')
const express = core.express
const clans = require('./clans.controller')
const { siteMode } = core.middleware
const clansRouter = express.Router()
clansRouter.get(
'/',
// #swagger.tags = ['Public · Example Game']
// #swagger.summary = 'Every clan the game has reported'
// #swagger.description = 'The clans this deployment knows about, in the game’s own vocabulary. Core calls these Teams and serves its own view of them at `/public/teams`; this route adds what core has no schema for. Answers with an empty list rather than failing when the game is unreachable — the list is a page, not a sync.'
/* #swagger.responses[200] = { description: 'The clans', content: { "application/json": { schema: { $ref: "#/components/schemas/ExamplegameClanList" } } } } */
siteMode,
clans.list,
)
clansRouter.get(
'/:externalId',
// #swagger.tags = ['Public · Example Game']
// #swagger.summary = 'One clan and its roster'
// #swagger.description = 'A clan by the game’s own id, with the roster as the game reported it. This is the module’s unprojected view of its OWN data and it deliberately withholds the member key and any linked account id — the roster core serves at `/public/teams/{slug}/roster` is the one that runs through `projectRoster`, and a module route that published more than core’s would route around its own visibility rules.'
/* #swagger.responses[200] = { description: 'The clan', content: { "application/json": { schema: { $ref: "#/components/schemas/ExamplegameClan" } } } } */
/* #swagger.responses[404] = { description: 'No such clan', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
siteMode,
clans.detail,
)
module.exports = clansRouter

View File

@@ -19,13 +19,21 @@
// fragment is generated from — `npm run swagger` parses this file (§2.8). Two
// rules that cost this project real time:
//
// • swagger-autogen reads these as JavaScript literals it evaluates. It
// re-quotes `"` and a backtick to `'` first, so either one inside a
// single-quoted description ends the string early — and when it cannot parse
// an annotation it drops that annotation, prints an error, and then reports
// success. Use a typographic apostrophe (’) in prose. `swaggerFragment.js`
// captures those errors and makes them fatal, which is the only reason you
// will find out.
// • swagger-autogen reads these as JavaScript literals it evaluates, so a
// QUOTE CHARACTER inside a single-quoted description ends the string early.
// Both `'` and `"` — use a typographic apostrophe (’) in prose, and rewrite
// a quoted phrase without the quotes. A backtick is fine: Markdown spans like
// `online: false` below survive verbatim, and the fragment shows them.
//
// **The failure is silent, and this is the part worth remembering.** It is
// not always a parse error you get told about. A `"` in the middle of a
// description truncates the value at that character — `'A "quoted" status'`
// becomes `A "` — while swagger-autogen prints `Success` in green and the
// error capture below sees nothing to capture, because nothing threw. The
// only signal is `npm run check:swagger` reporting the fragment stale, whose
// message will blame your routes. When it does and your routes did not
// change, look for a quote in an annotation before you look anywhere else.
// (Measured, not inferred: docs/modules/kit-acceptance.md, F5.)
// • A `\'` escape is valid JavaScript and wrong here: the annotation is never
// evaluated as JS by the reader, so Swagger UI renders the backslash.

View File

@@ -234,7 +234,17 @@ async function main() {
process.stderr.write('\nswagger-fragment.json is missing. Run `npm run swagger`.\n')
process.exit(1)
}
if (fs.readFileSync(FRAGMENT, 'utf8') !== json) {
// 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' +

View File

@@ -31,7 +31,7 @@ module.exports = {
tags: [
{
name: 'Public · Example Game',
description: 'Live world data, as last reported by the game server',
description: 'Live world data and the game’s clans, as last reported by the game server',
},
],
components: {
@@ -51,6 +51,55 @@ module.exports = {
},
},
},
ExamplegameClanList: {
type: 'object',
description: 'Every clan the game has reported (GET /public/clans).',
properties: {
stale: { type: 'boolean', example: false },
clans: {
type: 'array',
items: { $ref: '#/components/schemas/ExamplegameClanSummary' },
},
},
},
ExamplegameClanSummary: {
type: 'object',
properties: {
externalId: { type: 'string', example: 'clan-1' },
name: { type: 'string', example: 'The Gilded Company' },
abbr: { type: 'string', nullable: true, example: 'GC' },
memberCount: { type: 'integer', example: 3 },
},
},
ExamplegameClan: {
type: 'object',
description: 'One clan and the roster this viewer may see (GET /public/clans/{externalId}).',
properties: {
externalId: { type: 'string', example: 'clan-1' },
name: { type: 'string', example: 'The Gilded Company' },
abbr: { type: 'string', nullable: true, example: 'GC' },
memberCount: { type: 'integer', example: 3 },
projected: {
type: 'boolean',
description: 'Was the audience rule answered? False means the roster was withheld because the question could not be resolved — which is a different thing from a clan with no members.',
example: true,
},
members: {
type: 'array',
description: 'Deliberately carries no member key and no linked account id. Both exist and both go to core on the Team provider’s envelope; neither belongs on a public page.',
items: { $ref: '#/components/schemas/ExamplegameClanMember' },
},
},
},
ExamplegameClanMember: {
type: 'object',
properties: {
displayName: { type: 'string', nullable: true, example: 'Aldric' },
rankLabel: { type: 'string', nullable: true, example: 'Warlord' },
leader: { type: 'boolean', example: true },
online: { type: 'boolean', example: true },
},
},
},
},
}

View File

@@ -49,6 +49,11 @@ function fakeCtx(overrides = {}) {
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.
events: { emit: spy(undefined) },
middleware: {
requireAuth: (req, res, next) => next(),
requireRole: () => (req, res, next) => next(),
@@ -85,7 +90,10 @@ function fakeCtx(overrides = {}) {
* an operator's install.
*/
function fakeApi() {
const record = { routes: null, extensions: [], streams: null, legs: [], hooks: {} }
const record = {
routes: null, extensions: [], streams: null, legs: [], hooks: {}, teamProvider: null,
triggers: null, audiences: null, engagementSeeds: null,
}
const called = new Set()
const once = (name) => {
if (called.has(name)) throw new Error(`${name}() called twice`)
@@ -97,6 +105,15 @@ function fakeApi() {
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 },
onBoot(fn) { once('onBoot'); record.hooks.onBoot = fn },
onShutdown(fn) { once('onShutdown'); record.hooks.onShutdown = fn },
}

View File

@@ -0,0 +1,207 @@
// ── The Team provider, with no core and no database ───────────────────────
//
// The provider is the one part of a module that CORE calls, which makes it the
// one part whose failures reach further than its own pages: a wrong answer here
// is not a broken screen, it is core archiving Teams or departing members on your
// authority. So it gets the most tests in the template, and they are mostly about
// what it says when things are wrong.
//
// Everything is stubbed at the `.db.js` seam, the same way `worldStatus.test.js`
// does it. There is no database and no `ctx` — the provider only reaches core for
// its logger, and the one path that logs is exercised by installing a fake `ctx`.
const test = require('node:test')
const assert = require('node:assert')
const core = require('../core')
const db = require('../model/clans/clanProvider.db')
const settings = require('../model/clans/clanSettings')
const worldStatus = require('../model/worldStatus/worldStatus.model')
const provider = require('../model/clans/clanProvider.model')
const { fakeCtx } = require('./_fakes')
const CLAN = { externalId: 'clan-1', name: 'The Gilded Company', abbr: 'GC', memberCount: 2 }
const ROSTER = [
{ memberKey: 'char-001', displayName: 'Aldric', rankLabel: 'Warlord', isLeader: 1, isOnline: 1, userId: 7 },
{ memberKey: 'char-002', displayName: 'Bryn', rankLabel: 'Member', isLeader: 0, isOnline: 0, userId: null },
]
/** Swap out the db seam and the world-status read for one test. */
function withGame({ online = true, stale = false, clan = CLAN, roster = ROSTER, throws = null }, fn) {
const real = {
getPublicStatus: worldStatus.getPublicStatus,
findClan: db.findClan,
listClans: db.listClans,
listMembers: db.listMembers,
}
core._reset()
core.init(fakeCtx())
worldStatus.getPublicStatus = async () => ({ online, stale, players: 0, worldName: 'Example World', updatedAt: null })
db.findClan = async () => { if (throws) throw new Error(throws); return clan }
db.listClans = async () => { if (throws) throw new Error(throws); return clan ? [clan] : [] }
db.listMembers = async () => { if (throws) throw new Error(throws); return roster }
return Promise.resolve(fn()).finally(() => {
Object.assign(worldStatus, { getPublicStatus: real.getPublicStatus })
Object.assign(db, { findClan: real.findClan, listClans: real.listClans, listMembers: real.listMembers })
core._reset()
})
}
test('getTeams answers an envelope, not an array', () =>
withGame({}, async () => {
const answer = await provider.getTeams()
assert.strictEqual(answer.ok, true)
assert.strictEqual(answer.complete, true)
assert.strictEqual(answer.teams[0].externalId, 'clan-1')
// A bare array has exactly one shape for "I cannot answer" — `[]` — and it is
// the same shape as "there are none". The envelope exists to keep those two
// apart, so the array must never be the return value itself.
assert.ok(!Array.isArray(answer))
}))
test('an unreachable game REFUSES rather than reporting no clans', () =>
withGame({ online: false }, async () => {
// The most important assertion in this file. `{ ok: true, teams: [] }` reads
// as an authoritative "this deployment has no clans", and core acts on
// authoritative answers: it archives the Teams that are missing from one. A
// cold start would empty the site.
for (const answer of [
await provider.getTeams(),
await provider.getTeamMembers('clan-1'),
await provider.getTeamLeaders('clan-1'),
]) {
assert.strictEqual(answer.ok, false)
assert.ok(answer.reason, 'a refusal without a reason is what an operator has to debug from')
assert.strictEqual(answer.teams, undefined)
}
}))
test('stale data refuses too, even though the rows are readable', () =>
withGame({ online: true, stale: true }, async () => {
// The tables still hold a perfectly good snapshot, which is what makes this
// tempting to get wrong. Core cannot tell a snapshot five minutes old from one
// five days old, so an answer it would act on must be current.
assert.strictEqual((await provider.getTeams()).ok, false)
}))
test('a database error is caught and becomes a refusal', () =>
withGame({ throws: 'connection lost' }, async () => {
// Core reads a rejected promise as a refusal anyway. Catching it is what puts
// the module's own name on the log line, instead of an operator seeing core
// blamed for a fault in a module.
const answer = await provider.getTeams()
assert.strictEqual(answer.ok, false)
assert.match(answer.reason, /connection lost/)
}))
test('an empty roster is refused when the game says the clan is not empty', () =>
withGame({ roster: [] }, async () => {
// The clan row and the roster arrive on separate frames in any real ingest, so
// there is a window where this module knows a clan exists and not who is in
// it. Answering "nobody" there would have core depart every member.
const answer = await provider.getTeamMembers('clan-1')
assert.strictEqual(answer.ok, false)
assert.match(answer.reason, /has not arrived/)
}))
test('a genuinely empty clan is answered, not refused', () =>
withGame({ clan: { ...CLAN, memberCount: 0 }, roster: [] }, async () => {
// The other half of the rule above, and the reason `member_count` is in the
// schema at all: without a count from the game there is no way to tell these
// two cases apart, and a provider that refuses both can never report a clan
// emptying.
const answer = await provider.getTeamMembers('clan-1')
assert.strictEqual(answer.ok, true)
assert.deepStrictEqual(answer.members, [])
}))
test('members carry the contract shape, with userId resolved by this module', () =>
withGame({}, async () => {
const { members } = await provider.getTeamMembers('clan-1')
assert.deepStrictEqual(members[0], {
memberKey: 'char-001',
displayName: 'Aldric',
rankLabel: 'Warlord',
leader: true,
online: true,
userId: 7,
})
// Not linked to a site account is the ordinary case and must be `null` rather
// than absent or `0`: core stores it, and `0` is a user id.
assert.strictEqual(members[1].userId, null)
}))
test('getTeamLeaders answers keys, plurally', () =>
withGame({ roster: [...ROSTER, { ...ROSTER[0], memberKey: 'char-003', isLeader: 1 }] }, async () => {
const answer = await provider.getTeamLeaders('clan-1')
assert.deepStrictEqual(answer.leaders, ['char-001', 'char-003'])
// Core grants forum moderation and Team management from this list, so a
// provider that can only name one leader locks the others out of their own
// clan.
assert.ok(answer.leaders.length > 1)
}))
test('projectRoster returns member keys the caller supplied, in core’s snake_case', () =>
withGame({}, async () => {
// Core hands back the rows as IT stores them — this is the module's own data
// coming home — so the key is `member_key` and not the `memberKey` the
// provider sent out. Reading the wrong one silently answers with a list of
// `undefined`, which core filters to nothing: an empty roster with `ok: true`.
const answer = await provider.projectRoster('clan-1', [{ member_key: 'char-001' }], null)
assert.deepStrictEqual(answer, { ok: true, members: ['char-001'] })
}))
test('projectRoster fails CLOSED when it cannot resolve the question', () =>
withGame({}, async () => {
// The asymmetry that matters. The other three methods refuse and core keeps
// what it has; this one refuses and core serves an EMPTY roster, because for a
// visibility question "keep what you have" means publishing it. So a provider
// that cannot answer must say so rather than falling back to "show everything".
const real = settings.getRosterAudience
settings.getRosterAudience = async () => { throw new Error('settings unreadable') }
try {
const answer = await provider.projectRoster('clan-1', [{ member_key: 'char-001' }], null)
assert.strictEqual(answer.ok, false)
// Not `{ ok: true, members: [...everything] }`, which is the tempting
// fallback — the rows are right there and the lookup is the only thing that
// failed. That publishes a roster an operator may have gated to staff.
assert.strictEqual(answer.members, undefined)
} finally {
settings.getRosterAudience = real
}
}))
test('a members-only audience withholds from an anonymous viewer and answers for one inside', () =>
withGame({}, async () => {
const real = settings.getRosterAudience
settings.getRosterAudience = async () => 'members'
try {
const rows = [{ member_key: 'char-001' }, { member_key: 'char-002' }]
// Anonymous is an ANSWER — `{ ok: true }` with nothing visible — and not a
// refusal. A provider that refuses here tells core its rule broke, and core
// reports the roster as unavailable rather than as private.
const anon = await provider.projectRoster('clan-1', rows, null)
assert.deepStrictEqual(anon, { ok: true, members: [] })
// Aldric's account, resolved from this module's own roster — the only place
// the game↔site mapping exists.
const inside = await provider.projectRoster('clan-1', rows, { userId: 7, role: 'user' })
assert.deepStrictEqual(inside.members, ['char-001', 'char-002'])
// All or none. The audience is a property of the FEATURE, not of a member;
// there is no configuration in which half a roster is public.
const outside = await provider.projectRoster('clan-1', rows, { userId: 99, role: 'user' })
assert.deepStrictEqual(outside.members, [])
} finally {
settings.getRosterAudience = real
}
}))
test('pageUrlTemplate is a relative path carrying the substitution core makes', () => {
// Core substitutes `{externalId}` and does nothing else with it. A template
// naming its own host is refused at registration — there is no reason for a
// module to redirect the site's outbound mail — and so is a protocol-relative
// `//host/x`.
assert.match(provider.pageUrlTemplate, /^\/[^/]/)
assert.ok(provider.pageUrlTemplate.includes('{externalId}'))
})

View File

@@ -71,6 +71,54 @@ test('registers both lifecycle hooks', () => {
assert.strictEqual(typeof api.record.hooks.onShutdown, 'function')
})
test('registers a Team provider, with the three methods core requires', () => {
const { api } = register()
const provider = api.record.teamProvider
assert.ok(provider, 'no Team provider was registered')
// All three are required. A provider that could list Teams but not their
// members would leave core holding Teams it can never populate — which is not
// the same as a call that fails, and core refuses the registration rather than
// discovering it at the first sync.
for (const method of ['getTeams', 'getTeamMembers', 'getTeamLeaders']) {
assert.strictEqual(typeof provider[method], 'function', `provider.${method} is missing`)
}
// Optional, and asserted because THIS module supplies them. Delete the members
// and delete these two lines with them; do not leave a test claiming a contract
// you no longer meet.
assert.strictEqual(typeof provider.projectRoster, 'function')
assert.strictEqual(typeof provider.pageUrlTemplate, 'string')
})
test('the Team provider is claimed, not called, at registration time', () => {
const ctx = fakeCtx()
const { api } = register(ctx)
// Registration may not touch the database (§2.2) and every provider method
// reads one. That is legal precisely because core does not call any of them
// until it reconciles, which is after `onBoot` — so holding the object is the
// whole of what happens here.
assert.deepStrictEqual(ctx.db.query.calls, [])
assert.ok(api.record.teamProvider)
})
test('pageUrlTemplate points at a route this module registers', () => {
const { api } = register()
const template = api.record.teamProvider.pageUrlTemplate
// A relative path — core refuses one naming its own host, since there is no
// reason for a module to redirect the site's outbound mail.
assert.match(template, /^\/[^/]/)
assert.ok(template.includes('{externalId}'), 'core substitutes {externalId}; nothing else is a link')
// And it must be under this module's own namespace, because that is where core
// mounts every route this module registers. Nothing checks the two halves
// against each other — the client registers the route, the server declares the
// link — so this is the seam where a wrong answer becomes mail linking at a 404.
assert.ok(template.startsWith(`/${manifest.id}/`), 'the template is not under this module’s route namespace')
})
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+/)
@@ -82,3 +130,163 @@ test('the manifest declares what the loader requires', () => {
// 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')
})
// ── The engagement seam ───────────────────────────────────────────────────
//
// Core validates most of what is declared here at registration, and a module
// that gets it wrong does not load. These tests are mostly NOT that validator
// restated: they are the rules a module can satisfy at boot and still have got
// WRONG in a way whose only symptom is mail somebody received. Where one does
// overlap core — the namespacing and subjectKey assertions below — it is because
// `npm test` is a cheaper place to meet the failure than a first boot, and the
// message here names the field.
test('every declared trigger is namespaced, ceilinged, and carries examples', () => {
const { api } = register()
const triggers = api.record.triggers
assert.ok(Array.isArray(triggers) && triggers.length, 'no triggers were declared')
for (const t of triggers) {
// Trigger ids and notification-stream ids are ONE namespace, so an id must
// carry this module's own prefix or it is a claim on somebody else's.
assert.ok(t.id.startsWith(`${manifest.id}.`), `${t.id} is not namespaced`)
// `ceiling` is required and has no default: there is no safe value to guess.
assert.ok(t.ceiling, `${t.id} declares no ceiling`)
// Core refuses this one too; failing it here just costs less. What the rule
// protects is the cooldown key — "once per world", not "once per user" — and
// a subjectKey naming nothing would key every subject on `undefined`.
const names = t.variables.map((v) => v.name)
assert.ok(names.includes(t.subjectKey), `${t.id}: subjectKey "${t.subjectKey}" is not a variable`)
for (const v of t.variables) {
// Not decoration: the example is what makes a template previewable and
// test-sendable without waiting for a real game event.
assert.ok('example' in v, `${t.id}.${v.name} has no example`)
// The type set is closed. A payload that needs a structure has outgrown
// interpolation, and a template cannot walk one.
assert.ok(
['string', 'int', 'float', 'boolean', 'datetime', 'url'].includes(v.type),
`${t.id}.${v.name} has type "${v.type}"`,
)
// A url is site-relative, because it ends up in an href in a mail somebody
// opens days later.
if (v.type === 'url') assert.match(v.example, /^\/[^/]/, `${t.id}.${v.name} must be site-relative`)
}
}
})
test('an audience resolves to nobody rather than to everybody when it fails', async () => {
const ctx = fakeCtx({ db: { query: () => Promise.reject(new Error('database is down')), pool: {} } })
const { api } = register(ctx)
const audience = api.record.audiences[0]
// The one behaviour worth a test of its own. Core treats a throw the same way,
// so this is not core's guard restated — it is the module choosing the same
// answer deliberately, and the reason is that the alternatives are both worse:
// "everyone" mails the wrong people and a stale answer mails yesterday's.
assert.deepStrictEqual(await audience.resolve({ clanId: 'clan-1' }), [])
})
test('a seeded rule names only this module’s triggers, and its own or core’s templates', () => {
const { api } = register()
const seeds = api.record.engagementSeeds
const ownKeys = new Set(seeds.templates.map((t) => t.key))
const coreKeys = new Set(['notify.event', 'inapp.event', 'notify.digest'])
for (const t of seeds.templates) {
// The key column is UNIQUE across the whole table, so an unprefixed
// `notify.event` from a module would collide with core's body and win.
assert.ok(t.key.startsWith(`${manifest.id}.`), `template ${t.key} is not namespaced`)
// `protected` means "the system breaks without this body" — true of a
// password reset and of nothing a module ships. Core refuses a module
// template that sets it, because it would take an operator's delete button
// away.
assert.ok(!('protected' in t), `template ${t.key} may not mark itself protected`)
}
for (const group of seeds.ruleGroups) {
for (const rule of group.rules) {
assert.ok(
api.record.triggers.some((t) => t.id === rule.trigger_id),
`${rule.name} names a trigger this module does not declare`,
)
for (const key of Object.values(rule.template_keys)) {
assert.ok(ownKeys.has(key) || coreKeys.has(key), `${rule.name} names an unknown template ${key}`)
}
// `enabled` is not a parameter, and a value passed for it is ignored
// rather than refused. Passing one anyway states an intention the platform
// will not honour, so the honest thing is not to write it.
assert.ok(!('enabled' in rule), `${rule.name} may not seed itself enabled`)
}
}
})
test('every seeded body is a shape the block registry will accept', () => {
const { api } = register()
// The gap this exists for: `registerEngagementSeeds` checks that `blocks` is a
// non-empty array and stops. The BODY is validated by core's block registry,
// which runs in the template editor and in the renderer — so a malformed block
// registers, seeds, and first shows itself when an operator opens the body or a
// rule fires. Core is not here to ask, so assert the two rules that are easy to
// get wrong and impossible to notice.
const HEADING_LEVELS = ['h1', 'h2', 'h3']
for (const t of api.record.engagementSeeds.templates) {
const ids = new Set()
for (const block of t.blocks) {
// Every block carries its own id, unique within the body: it is how the
// editor addresses one block, and how `inapp.event`'s renderer maps blocks
// onto the inbox row's columns by role.
assert.ok(block.id && typeof block.id === 'string', `${t.key}: a block has no id`)
assert.ok(!ids.has(block.id), `${t.key}: two blocks share the id "${block.id}"`)
ids.add(block.id)
assert.ok(block.type.startsWith('email.'), `${t.key}: ${block.type} is not an email block`)
// A heading's `level` is a SIZE token, not a number. `{ level: 2 }` reads
// perfectly and is refused, and it renders at the default size in any
// preview that skips validation — which is the whole trap.
if (block.type === 'email.heading') {
assert.ok(
HEADING_LEVELS.includes(block.props.level),
`${t.key}: heading level "${block.props.level}" must be one of ${HEADING_LEVELS.join(', ')}`,
)
}
}
}
})
test('the world event fires on the transition and not on the poll', async () => {
const boot = require('../boot')
// Online already, and reporting online again. The refresh writes, and nothing
// is announced: this runs every thirty seconds, and a rule on an event fired
// every thirty seconds mails somebody every thirty seconds. Core's cooldown
// would hold — but leaning on it means emitting "still up" and calling it news.
const steady = fakeCtx({ db: { query: () => Promise.resolve([{ online: 1 }]), pool: {} } })
require('../core')._reset()
require('../core').init(steady)
await boot.refresh()
assert.deepStrictEqual(steady.events.emit.calls, [])
// Offline before, online now. One emit, with the declared payload.
const flipped = fakeCtx({ db: { query: () => Promise.resolve([{ online: 0 }]), pool: {} } })
require('../core')._reset()
require('../core').init(flipped)
await boot.refresh()
assert.strictEqual(flipped.events.emit.calls.length, 1)
const [triggerId, envelope] = flipped.events.emit.calls[0]
assert.strictEqual(triggerId, 'examplegame.world.status_changed')
assert.strictEqual(envelope.data.status, 'online')
// A fresh install, where there is no previous row at all. Not a change — and
// announcing it would tell everyone the world came online the first time an
// operator started the site.
const fresh = fakeCtx({ db: { query: () => Promise.resolve([]), pool: {} } })
require('../core')._reset()
require('../core').init(fresh)
await boot.refresh()
assert.deepStrictEqual(fresh.events.emit.calls, [])
})

View File

@@ -0,0 +1,124 @@
// ── §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.
//
// ── WHEN YOU ADD A SIDECAR CLIENT, NARROW THIS. DO NOT DELETE IT. ─────────
//
// Talking to *your sidecar* over HTTP is the expected shape and is not what §2.7
// forbids — the rule is about the **game server**. So the moment your module
// grows, say, `server/sidecarClient.js`, this test starts failing correctly and
// the fix is to allow that one file:
//
// const MAY_OPEN_SOCKETS = new Set(['sidecarClient.js'])
//
// and keep the rest of the tree under the ban. What you get for that is a test
// that names the *one* file allowed to reach the network — which is exactly the
// file a reviewer should be reading closely, and exactly the place a game-server
// URL would appear if the rule were ever broken.
//
// 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'])
/** 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()) {
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('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`)
}
})

View File

@@ -1,5 +1,73 @@
{
"paths": {
"/api/v1/public/clans": {
"get": {
"tags": [
"Public · Example Game"
],
"summary": "Every clan the game has reported",
"description": "The clans this deployment knows about, in the game’s own vocabulary. Core calls these Teams and serves its own view of them at `/public/teams`; this route adds what core has no schema for. Answers with an empty list rather than failing when the game is unreachable — the list is a page, not a sync.",
"responses": {
"200": {
"description": "The clans",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ExamplegameClanList"
}
}
}
},
"500": {
"description": "Internal Server Error"
}
}
}
},
"/api/v1/public/clans/{externalId}": {
"get": {
"tags": [
"Public · Example Game"
],
"summary": "One clan and its roster",
"description": "A clan by the game’s own id, with the roster as the game reported it. This is the module’s unprojected view of its OWN data and it deliberately withholds the member key and any linked account id — the roster core serves at `/public/teams/{slug}/roster` is the one that runs through `projectRoster`, and a module route that published more than core’s would route around its own visibility rules.",
"parameters": [
{
"name": "externalId",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "The clan",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ExamplegameClan"
}
}
}
},
"404": {
"description": "No such clan",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"500": {
"description": "Internal Server Error"
}
}
}
},
"/api/v1/public/world/status": {
"get": {
"tags": [
@@ -28,7 +96,7 @@
"tags": [
{
"name": "Public · Example Game",
"description": "Live world data, as last reported by the game server"
"description": "Live world data and the game’s clans, as last reported by the game server"
}
],
"components": {
@@ -127,6 +195,300 @@
}
}
}
},
"ExamplegameClanList": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "object"
},
"description": {
"type": "string",
"example": "Every clan the game has reported (GET /public/clans)."
},
"properties": {
"type": "object",
"properties": {
"stale": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "boolean"
},
"example": {
"type": "boolean",
"example": false
}
}
},
"clans": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "array"
},
"items": {
"$ref": "#/components/schemas/ExamplegameClanSummary"
}
}
}
}
}
}
},
"ExamplegameClanSummary": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "object"
},
"properties": {
"type": "object",
"properties": {
"externalId": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "string"
},
"example": {
"type": "string",
"example": "clan-1"
}
}
},
"name": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "string"
},
"example": {
"type": "string",
"example": "The Gilded Company"
}
}
},
"abbr": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "string"
},
"nullable": {
"type": "boolean",
"example": true
},
"example": {
"type": "string",
"example": "GC"
}
}
},
"memberCount": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "integer"
},
"example": {
"type": "number",
"example": 3
}
}
}
}
}
}
},
"ExamplegameClan": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "object"
},
"description": {
"type": "string",
"example": "One clan and the roster this viewer may see (GET /public/clans/{externalId})."
},
"properties": {
"type": "object",
"properties": {
"externalId": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "string"
},
"example": {
"type": "string",
"example": "clan-1"
}
}
},
"name": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "string"
},
"example": {
"type": "string",
"example": "The Gilded Company"
}
}
},
"abbr": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "string"
},
"nullable": {
"type": "boolean",
"example": true
},
"example": {
"type": "string",
"example": "GC"
}
}
},
"memberCount": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "integer"
},
"example": {
"type": "number",
"example": 3
}
}
},
"projected": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "boolean"
},
"description": {
"type": "string",
"example": "Was the audience rule answered? False means the roster was withheld because the question could not be resolved — which is a different thing from a clan with no members."
},
"example": {
"type": "boolean",
"example": true
}
}
},
"members": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "array"
},
"description": {
"type": "string",
"example": "Deliberately carries no member key and no linked account id. Both exist and both go to core on the Team provider’s envelope; neither belongs on a public page."
},
"items": {
"$ref": "#/components/schemas/ExamplegameClanMember"
}
}
}
}
}
}
},
"ExamplegameClanMember": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "object"
},
"properties": {
"type": "object",
"properties": {
"displayName": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "string"
},
"nullable": {
"type": "boolean",
"example": true
},
"example": {
"type": "string",
"example": "Aldric"
}
}
},
"rankLabel": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "string"
},
"nullable": {
"type": "boolean",
"example": true
},
"example": {
"type": "string",
"example": "Warlord"
}
}
},
"leader": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "boolean"
},
"example": {
"type": "boolean",
"example": true
}
}
},
"online": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "boolean"
},
"example": {
"type": "boolean",
"example": true
}
}
}
}
}
}
}
}
}