Compare commits

...

13 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
35 changed files with 3004 additions and 232 deletions

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

View File

@@ -149,9 +149,9 @@ Two more things that look like your module failing and are not:
Restart core and read the log. A module that loaded says so:
```
INFO [examplegame] registered {"version":"0.1.0","routes":"public:/world"}
INFO [modules] registered module "examplegame" v0.1.0 {"mounts":{"public":["/world"]}}
INFO [modules] schema ensured for module "examplegame" {"statements":2}
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
```
@@ -165,7 +165,13 @@ 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.
- **`/api/v1/public/world/status`** answers JSON.
- **`/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.

View File

@@ -147,8 +147,9 @@ statement of your dependencies, and it makes a test double for it — see
## What you register
Seven calls, all synchronous, all documented in [§2.4][api]. What is worth knowing
is not their signatures but the model behind them.
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
@@ -183,8 +184,8 @@ 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. **Only core may
declare a slot; a module may only fill one**, and one module per slot.
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.**
@@ -194,6 +195,63 @@ 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
@@ -222,6 +280,208 @@ 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
@@ -388,9 +648,16 @@ every case than one that shows a link which then answers `403`.
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, and read-only access to the
session and site settings. Enough to build a page that looks like the site it is
installed in, and nothing else.
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`
@@ -413,6 +680,14 @@ 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.
@@ -489,11 +764,30 @@ 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 declared in `module.json`, not computed from commit subjects.**
You already have one authoritative version — it is what core records and what the
admin panel shows — and two sources for one number is how they drift. A release
happens when a push to `main` leaves a version that has no release yet, so
bumping is an ordinary reviewed change and publishing is the workflow's business.
**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.
@@ -514,6 +808,7 @@ where you publish.
| 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

View File

@@ -132,28 +132,46 @@ 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.
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.
Rust — the survival game — is the worked example here, in
[`rust-dryrun.md`][dryrun]: a module designed on paper for a game chosen for how
little it shares with Ultima Online. Rust ships RCON over WebSocket, so a
`rust-link` has no protocol to invent and no game-side plugin to write at all. It
keeps:
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".
- the RCON connection, its credentials and its reconnect loop, **out of an Express
process** — where the failure mode is a wedged request handler;
- a store, so the site is not blank whenever the game is restarting, which for that
genre is a daily scheduled event;
- an HTTP + WS API with a version on it, so the module talks to one shape of thing
regardless of what the game speaks.
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.
It drops the bespoke wire protocol and the plugin. That is what "thin" means: less
code, not a different 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.
That document originally concluded the opposite — "no sidecar, the module dials
RCON directly" — and it carries a dated correction saying so, rather than having
been quietly rewritten. The value of a dry run is the record of what it found,
including where it was overruled.
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

View File

@@ -4,10 +4,17 @@ 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 speaks a remote-control protocol, you may not need any of
this — see the end of [chapter 3](03-sidecar.md). If it does not, 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.
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

View File

@@ -1,24 +1,47 @@
{
"repo": "https://gitea.whitlocktech.com/RunicGateway/website.git",
"branch": "edge",
"ref": "4ad8b2bb0ede2747622075dcfa4cb1fe460f91ca",
"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.5.0 bump, which is the version",
"template/module.json declares. It moved here from the 1.4.0 bump because",
"the kit's acceptance run found PublicLayout had no way to give a module",
"page the site's body wrapper, and core grew a `shell` prop for it - so the",
"template now uses a member that only exists at this ref and later.",
"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

@@ -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

@@ -24,8 +24,14 @@ 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 = []) {

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,12 +301,20 @@ 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/`.
@@ -177,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"
@@ -213,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,12 +282,20 @@ 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/`.
@@ -163,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"
@@ -196,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:
@@ -239,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" \

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 UI kit (eight exports)
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
@@ -106,21 +112,29 @@ backticking table names**.
| `.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` |
@@ -133,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,10 +45,17 @@ if (createElement !== rg.react.createElement || createRoot !== rg.reactDom.creat
)
}
// The curated kit (§3.4). Eight exports, 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 an
@@ -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

@@ -48,7 +48,12 @@ export default function WorldStatus() {
<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.5.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

@@ -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

@@ -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
}
}
}
}
}
}
}
}
}