18 Commits

Author SHA1 Message Date
990a50b491 Merge pull request 'feat(guilds): a third place on the guild page, and where that page lives' (#13) from feature/teams-phase6-notifications into edge
Reviewed-on: #13
2026-08-18 23:10:02 +00:00
c57310c505 feat(guilds): a third place on the guild page, and where that page lives
All checks were successful
PR Checks / server-tests (pull_request) Successful in 23s
PR Checks / client-build (pull_request) Successful in 17s
PR Checks / frozen-manifest (pull_request) Successful in 34s
Two lines only core cannot supply for itself.

`uo.guild.header` is a third declared slot, at the top of the page, for core's
per-Team notification control. A third rather than a corner of the feed because a
slot holds one component and the first fill wins: the control is an action ON this
page and the other two are content IN it, and separate slots are what let this
module say so.

`pageUrlTemplate` tells core where a guild page actually is. Teams are a contract
primitive with no core surface — core owns the tables and the access rules, this
module owns the word "guild" and therefore the page — which leaves core unable to
write a link to one. A notification email that cannot take you to the thread it is
about is most of the way to useless. Core substitutes `{externalId}` and does
nothing else with it; a template naming its own host is refused at registration.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-18 14:35:36 -05:00
7ce78e303c Merge pull request 'feat(guilds): declare a second place on the guild page, for core's forum' (#12) from feature/teams-phase4-forum-slot into edge
Reviewed-on: #12
2026-08-18 14:17:46 +00:00
46e3f5a127 ci(core-ref): bump the pin past registerTeamProvider
All checks were successful
PR Checks / client-build (pull_request) Successful in 15s
PR Checks / server-tests (pull_request) Successful in 19s
PR Checks / frozen-manifest (pull_request) Successful in 42s
`frozen-manifest` has been failing since Teams phase 2, on this PR and on #11
before it, for a reason that has nothing to do with either: the pinned core
(website#140, the module-system de-UO slice) predates `api.registerTeamProvider`,
which this module has called since phase 1 of its Teams work. The module therefore
fails to LOAD in the pinned checkout — "api.registerTeamProvider is not a function"
— and a module that does not load adds no routes, which the job correctly reports
as the module having removed everything it serves.

So the red was real and was pointing at the pin, exactly as the pin's own comment
says it should: core moves for reasons that have nothing to do with this module,
and a bump is a deliberate commit saying which core the module was last proved
against.

Bumped to `edge` at Teams phase 3 (website#152) — the first core that has both
`registerTeamProvider` and the roster projection this module now implements.
Reproduced the whole job locally against that core: core's own manifest is current
at the new pin, the module loads, and the difference is 73 routes, all documented.
`routes.manifest.json` is unchanged and needed no regeneration, which is the
expected result for a client-only change.

Not bumped to the phase 4 core, deliberately: that is website#153 and is not merged
yet. Nothing in this module needs it — the second slot is a client-side
declaration, invisible to the route manifest.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-18 07:31:58 -05:00
9d0a197008 feat(guilds): declare a second place on the guild page, for core's forum
Some checks failed
PR Checks / client-build (pull_request) Successful in 14s
PR Checks / frozen-manifest (pull_request) Failing after 34s
PR Checks / server-tests (pull_request) Successful in 8m40s
The mirror of the activity feed, one phase later. Core owns the Team forum —
membership, manual grants and the member/guest split are all core's rules, and a
module reimplementing any of them would be reimplementing a security boundary — but
core publishes no Team page, because it does not own the word "guild". So this
module declares the place and core puts the forum in it.

TWO declarations rather than one, and that is the interesting part. A slot holds one
component and the first fill wins, so folding the forum into `uo.guild.detail`
alongside the feed would hand core the decision about where each of its two
contributions sits on a page this module owns. Separate slots also keep them
independent: with the forum switched off, the feed renders exactly as before.

The registration test now asserts the set of declared slots and that EVERY one of
them is rendered by the page that owns it, rather than naming a single slot twice.
A slot nothing renders is a slot core fills into the void.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-18 07:24:37 -05:00
eb30e4ae37 Merge pull request 'feat(guilds): the roster projection, a guild detail page, and the slot core fills' (#11) from feat/teams-phase3-projection-slot into edge
Reviewed-on: #11
2026-08-18 02:12:18 +00:00
dda0e32dd3 feat(guilds): a guild detail page, and the slot core puts the feed in
Some checks failed
PR Checks / client-build (pull_request) Successful in 16s
PR Checks / frozen-manifest (pull_request) Failing after 33s
PR Checks / server-tests (pull_request) Successful in 8m46s
The module's half of the org lead's correction: Teams is the contract, guilds
are the presentation, and the presentation is this module's.

Adds `/uo/guilds/:id` — the detail view the board never had — with the roster
from this module's OWN board, which is the same data it answers core's Team
provider from. Reading core's projection of our own answer back would be a round
trip through a staler copy of it.

The page declares `uo.guild.detail` and core fills it with the Team activity
feed. That is the one part of this page core cannot hand over: only core can
resolve whether the viewer is inside the Team, and the public/members split on
that feed is a security boundary. The guild is named in OUR terms — core maps
its own Team from the module id and the external id — so this module never holds
core's row id or slug.

`TeamOverviewStrip` is deleted with the core Team page it filled.
`team.member.row` is not declared here either: the useful thing to put in a
roster row is a link to the character behind it, and nothing core could supply
identifies one.

`GET /public/shard/guilds/:id` backs the page, gated and projected through the
same `guilds` feature as the board — so an operator who raises that audience
raises this too, and the locked acct/webId fields never survive below admin. A
roster is where those appear in bulk, which makes this the endpoint where
getting the projection wrong would matter most.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-17 20:58:30 -05:00
d4aa5ade12 feat(teams): project rosters by audience rung, and add to the Team page
The module's half of TEAMS.md phase 3.

`projectRoster` is the optional fourth provider method and the only one core
calls on a request path. Core holds the roster and owns its public shape; the
question that is this module's is who is allowed to look, because the audience
rungs and their configuration live here.

The answer is all-or-nothing, which is the honest translation rather than a
shortcut: a rung is a property of the FEATURE, and there is no configuration in
which some members of a guild are public and others are not.

The refusal semantics INVERT here, and the tests say so. For the other three
methods a refusal means "change nothing" and an empty array would be
destructive. Core fails CLOSED on this one, so the dangerous answer is the
opposite — returning every key because the config could not be read would
publish a roster an operator gated to staff. Every path that cannot reach a
confident answer refuses, including the catch.

The anonymous case is answered directly rather than by handing `viewerLevel` a
synthetic request. Given one with no `req.user` it falls through to
`auth.getUserFromRequest`, which expects real cookies and throws on a fake — and
that throw would have become a refusal, so every anonymous visitor would have
been served an empty roster on a shard whose guilds are public. Caught by the
tests, not by reading.

`team.overview` gets a live population reading beside core's stored one. Core's
number comes from the last roster sync and is coarse by construction; this is
the `presence.online` feed this module already holds. It is explicitly not a
per-Team presence figure — the shard publishes a global aggregate and no
per-guild breakdown exists on the wire, so claiming one would be inventing a
number — and it renders nothing at all when it has nothing true to say.

`team.member.row` is left unfilled. The useful thing to put there is a link to
the character behind a row, and the props core can supply do not identify one:
the member key and the site account id are withheld from every public roster.
An empty cell beats a guess.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-17 20:16:22 -05:00
0d618599cf Merge pull request 'feat(teams): ingest guild rank, and report every leader rather than one' (#10) from feat/protocol4-guild-rank into edge
Reviewed-on: #10
2026-08-17 22:48:54 +00:00
76b2321f25 Merge pull request 'feat(teams): answer core's Team provider from the guild board' (#9) from feat/teams-phase2-provider into edge
Reviewed-on: #9
2026-08-17 22:48:20 +00:00
99d1ca25a7 feat(teams): ingest guild rank, and report every leader rather than one
Some checks failed
PR Checks / client-build (pull_request) Successful in 15s
PR Checks / server-tests (pull_request) Successful in 20s
PR Checks / frozen-manifest (pull_request) Failing after 34s
The module half of the Protocol 4 rank amendment (servuo-plugins, same wire
version -- Protocol 4 is unreleased on `edge`, so it is amended rather than
bumped).

`shard_guild_members` gains `rank`, `rank_cliloc` and `rank_name`. The provider
then answers the question it previously could not: `getTeamLeaders()` returns
EVERY member at rank 4, not just the board's single `leader_serial`. That
limitation was the whole reason the wire grew a per-member rank -- TEAMS.md §2.5
treats multiple leaders as the normal case and core has always supported them.

The board's `leader_serial` is folded in as a floor rather than replaced. It
comes from a different frame, so on a shard whose roster has not been re-emitted
since the amendment it is the only leadership signal there is, and moving to
ranks must not lose it.

## NULL rank is a real state, and it is load-bearing

The shard withholds the rank for a staff account, because ServUO's
`PlayerMobile.GuildRank` reports Leader for anyone at GameMaster or above
whatever their actual rank. Every layer here preserves that:

  - the ingest stores NULL rather than defaulting to 0, which would be a
    demotion this code invented;
  - `leader` requires an integer rank >= 4, so absence is never leadership;
  - the leaders query compares on `rank`, and NULL is excluded by the comparison.

Reading a missing rank as either 0 or "leader" would republish the exact lie the
shard went out of its way not to send.

## Rank labels

Three sources, in order: a custom rank's literal string, then the operator's
cliloc table, then the five standard names. The last exists because the cliloc
table is populated only if someone ran the client-file extraction, and a roster
on a shard that has not should still read "Warlord" rather than nothing. A
failing lookup falls back rather than failing the roster -- a label is decoration,
and losing it must not lose the data.

`rank` is backticked everywhere it is written, like `int` on shard_online: it is
reserved in MySQL 8 and merely a keyword in MariaDB, so it parses bare here and
must not be relied on to.

The schema fragment carries ALTERs as well as the CREATE. No production install
has this table -- it is new in an unreleased protocol -- but `edge` deployments do,
from the roster work that landed before the amendment, and CREATE TABLE IF NOT
EXISTS adds a table and never a column. Same gap the sidecar's own store hit when
`guilds.members` was added.

## Verification

The unit tests stub the db layer, so the round trip was proved separately: the
VERBATIM roster frame captured from the live ServUO run was fed through the real
ingest into MariaDB and then read back through the provider.

  stored:   0x1F5 rank=4  0x1F6 rank=3  0x1F7 rank=2  0x1F8 rank=1
            0x1F9 rank=NULL (the GameMaster)  0x2E0 rank=4
  provider: leaders = [0x1F5, 0x2E0]   <- two, which the board alone cannot express
            labels  = Leader / Warlord / Emissary / Member, with no cliloc table
            0x1F9   = not a leader, no label

9/9 checks. Suite 413 -> 421 tests, all passing.

Refs docs/link/v4.md §2.3, docs/website/TEAMS.md §2.5

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-17 17:42:32 -05:00
c6929c6bae fix(teams): take query from the core facade, not a core.db that does not exist
Some checks failed
PR Checks / client-build (pull_request) Successful in 23s
PR Checks / server-tests (pull_request) Successful in 29s
PR Checks / frozen-manifest (pull_request) Failing after 40s
The Team provider's db layer built its query helper as `core.db.query(...)`. The
facade has no `db` member -- every other *.db.js in this module destructures
`query` from it directly -- so every call threw `Cannot read properties of
undefined (reading 'query')`.

The failure mode is the bad part. That throw is caught by the provider's own
error handling and turned into `{ ok: false, reason: 'roster unreadable: …' }`,
which is a perfectly valid refusal -- so core would have accepted it, held the
projection it had, and reported staleness. A provider that answers correctly and
never returns data, forever, with nothing in any log louder than a warning.

Invisible to the unit tests because they stub every db function, so the helper
was never called. Found by running a real roster frame through the ingest and
then asking the provider what it saw, against the real database.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-17 17:41:22 -05:00
51e58104bf Merge pull request 'feat(teams): answer core's Team provider from the guild board' (#9) from feat/teams-phase2-provider into edge
Reviewed-on: #9
2026-08-17 22:19:01 +00:00
268449f2a6 feat(teams): answer core's Team provider from the guild board
Some checks failed
PR Checks / client-build (pull_request) Successful in 16s
PR Checks / server-tests (pull_request) Successful in 21s
PR Checks / frozen-manifest (pull_request) Failing after 44s
A UO guild is a Team. This registers module-uo as the authoritative source of
them (MODULE_API 1.6.0, docs/website/TEAMS.md §2.3) and answers the three
questions core asks, from the board and the roster Protocol 4 put there.

`externalId` is the persistent ServUO `Guild.Id`, which survives a rename -- so
core sees "an id whose name changed" and applies its rename rule rather than an
unrelated new guild appearing beside the old one. That mapping is this module's
to make: only the game knows what identity survives what.

The most important code here is the refusal guard, and it is deliberately
conservative. Core's contract is that module unavailability becomes staleness and
never emptiness, and this module is the only thing that can honour it -- an empty
array from here reads as an authoritative "there are none", and core archives
Teams and departs members from an authoritative answer. Three states refuse: no
uo-link configured, the integration disabled, and the socket not connected.

**The third is the one worth arguing about.** The board is durable and survives an
outage, so serving it while disconnected looks harmless. It is not: core cannot
tell a board five minutes stale from one five days stale, and a complete answer
licenses destruction. There is a test named for that.

A fourth refusal has no equivalent anywhere else: a guild whose roster has not
arrived. Protocol 4's roster comes on its own frames, separately from the
`guild.update` that creates the board row, so there is a real window where a
155-member guild has zero roster rows. The board's own `members` count is the only
thing that distinguishes "the roster is late" from "this guild is empty", and it
is checked -- with the count in the refusal message, because it is the evidence.
The other side is tested too: when the board says zero, an empty roster is the
truth and withholding it would freeze a disbanding guild's membership forever.

Two limitations, both honest and both in the code as comments:

  - **`rankLabel` is null.** The wire's roster member is the standard actor object
    (`serial`, `name`, `player`, `acct?`, `webId?`) and carries no guild rank.
    Inventing a label from the leader flag would be core displaying something this
    module made up.

  - **One leader, not several.** TEAMS.md §2.5 expects multiple leaders from
    `GuildRank.Rank >= 4` and core supports them, but Protocol 4 does not put rank
    on the wire, so the only leadership visible here is the board's single
    `leader_serial`. Raising it to the full set is a protocol change, not
    something this module can fix.

`online` comes from `shard_online` rather than the roster, which carries no
per-member presence and only a board-level count -- the same source the public
"who's online" surface already uses. `userId` prefers the roster's own `web_id`
(what the shard asserted at roster time) and falls back to the `shard_account_links`
join for a member whose row predates their link; resolving it here rather than in
core is the contract, since core reading `shard_account_links` would be core
naming a module's table.

`coreApi` stays `^1.3.0` -- 1.6.0 satisfies it, which is what makes the bump minor.

18 provider tests plus two on the entry point: that all three methods are
registered, and that registration performs no query. The second matters because
register() runs while core's app.js is still being required with the pool pointed
at a dead port, which both routeManifest.js and swagger.js depend on.

`fakeApi` gained `registerTeamProvider` with the same `once` rule core applies --
one provider per deployment, so a second registration has to fail here too rather
than passing a shape core rejects at load.

411 -> 413 tests, all passing.

Refs docs/website/TEAMS.md §2.3, Part 12 phase 2

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-17 15:31:58 -05:00
e93361aa48 Merge pull request 'feat(shard): ingest guild rosters and departures (protocol 4)' (#8) from feat/teams-phase1-guild-roster into edge
Reviewed-on: #8
2026-08-17 19:29:05 +00:00
2fa4d87a40 feat(shard): ingest guild rosters and departures (protocol 4)
All checks were successful
PR Checks / client-build (pull_request) Successful in 15s
PR Checks / server-tests (pull_request) Successful in 19s
PR Checks / frozen-manifest (pull_request) Successful in 38s
Protocol 2 gave the guild board a member *count* and nothing else, so the Guilds
page could say a guild had 155 members but never who they were, and
findGuildForActor deliberately answered only for leaders because membership for
rank-and-file was not in the feed at all. Protocol 4 puts it there.

`shard_guild_members` holds one row per member per guild, keyed on
(guild_id, serial). `guild.roster` replaces a guild's rows; `guild.leave` removes
one. A guild.remove now clears the membership too, so a disbanded guild does not
leave orphaned rows behind.

The chunking needs explaining. A roster over the shard's per-frame cap arrives as
several frames carrying seq/more/total. The sidecar reassembles them for its own
GET /guilds board, but the live WebSocket feed and the /history backfill both
carry the individual frames — so this ingest sees them unreassembled.

It copes without buffering, because a table expresses what the sidecar's single
JSON column could not: the frame carrying seq 0 clears the guild first, and every
frame then upserts its own rows. Upsert rather than insert because the /history
backfill replays stored frames on every reconnect, and a redelivery has to be a
no-op rather than a duplicate-key error. The cost is a sub-second window during a
multi-frame update where the table holds part of a roster; buffering to close it
would duplicate the sidecar's reassembly for a projection that is already only as
fresh as a 60s sweep.

On visibility: both kinds are mapped to the existing `guilds` feature. Without
that mapping rule 2 fails an unmapped kind closed to admin-only, which would have
quietly kept rosters off the public page forever. Mapping them is safe because a
roster is the first frame carrying locked fields inside an ARRAY of actors rather
than one nested actor, and the projection walker already recurses into arrays and
matches acct/webId by suffix — so a member's account name is stripped below admin
by exactly the rule that already strips guild.leader.acct. There is a test for
that specifically, because the difference is a public page listing character names
versus one publishing 150 account names.

`acct`/`web_id` are still stored, since that is what lets a linked member be
matched to a site user; they are just never projected below admin.

guild.leave is appended to the event log, as the departure counterpart to
guild.join and for the same reason — it is what a "so-and-so left" feed reads.
guild.roster stays out: it is board state like guild.update, and it is the one fat
frame on the wire, so logging it would put a full membership snapshot into
shard_events on every membership change.

The PUBLIC_KINDS guard test caught the addition, which is what it is for; its
expected set now carries a v4 group alongside the v3 one.

Refs: docs/website/TEAMS.md Part 12 Phase 1

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-17 12:57:26 -05:00
97e2fddfcd Merge pull request 'chore(ci): scan this repo with SonarQube (phase 4, slice 0)' (#7) from chore/sonarqube into main
All checks were successful
Release / release (push) Successful in 8s
SonarQube / analysis (push) Successful in 1m54s
Reviewed-on: #7
2026-08-12 07:37:39 +00:00
62c8ee68b4 chore(ci): scan this repo with SonarQube (phase 4, slice 0)
All checks were successful
PR Checks / client-build (pull_request) Successful in 14s
PR Checks / frozen-manifest (pull_request) Successful in 34s
PR Checks / server-tests (pull_request) Successful in 8m50s
Until now this was the one part of the platform that had never been scanned.
The 75 files here arrived in the Phase 3 extraction and left their Sonar
history behind in core's project, so a whole module's worth of shipped code
has no dashboard at all.

Adds sonar-project.properties (project key Module-uo) and a sonarqube.yml
mirroring website's: push to main, never a PR gate, nothing waiting on the
quality gate.

Two things differ from core's config, both because this repo is shaped
differently:

  - There is no src/ to point sonar.sources at — the server half keeps
    boot.js/core.js/index.js at server/ root beside its subdirectories — so
    the whole tree is included and the non-source parts are excluded. That
    direction is deliberate: a new top-level server directory is scanned by
    default rather than silently unscanned.
  - server/scripts and client/scripts are IN. checkImports.js and
    checkExternals.js are the enforcement of MODULE_API.md 5.1 and 3.6, they
    carry their own test suites, and both have already shipped defects a
    reviewer missed. Build code that decides whether a release is allowed out
    is not throwaway code.

The workflow builds the client chunk before running either suite, for the
reason pr-checks.yml already calls load-bearing: build.test.js and
registration.test.js read dist/entry.js and SKIP without it, so the other
order reports coverage for a suite that quietly asked less than it looks like
it did.

Both suites run from the repo root rather than with --prefix, so the LCOV SF:
paths come out repo-root-relative and resolve against sonar.sources. That is
why the server suite's --require is spelled out here instead of reusing
`npm test --prefix server`, whose path is relative to server/.

Verified locally: 385 server test cases across 57 covered files and 40 client
cases, both LCOV and Generic Test Execution XML well-formed with
repo-root-relative paths.

Needs one-time setup in the Gitea UI before it can run — secret SONAR_TOKEN
and variable SONAR_HOST_URL, same as the other repos.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-12 02:36:11 -05:00
30 changed files with 1755 additions and 16 deletions

View File

@@ -61,12 +61,19 @@
#
# Runner: the shared self-hosted `ubuntu-latest` runner. These jobs need only
# Node — no Docker socket, no database.
#
# Scope note: `edge` is gated as well as `main`. Multi-phase work lands there
# first, so gating only the `main` hop would run these checks for the first time
# at the cutover — the one moment a red build is most expensive to discover. This
# is the same call `RunicGateway/installer` made for the same reason, and it was
# taken here after a nine-PR Android workstream landed on an ungated `edge` with
# no CI at all. Adding a branch to the `branches:` list is the whole change.
name: PR Checks
on:
pull_request:
branches: [main]
branches: [main, edge]
# A newer push to the same PR cancels the in-flight run.
concurrency:

View File

@@ -0,0 +1,103 @@
# Run SonarQube static analysis against the code that just landed on `main` and
# report the results to the self-hosted SonarQube server for review. This is
# intentionally NON-BLOCKING: it triggers on push to main (i.e. AFTER merge),
# not on pull_request, so it never gates a PR. It complements pr-checks.yml
# (which gates PRs) and release.yml (which publishes the bundle) — this one only
# feeds the dashboard.
#
# Mirrors RunicGateway/website's sonarqube.yml, for the same reason pr-checks.yml
# does: this module is two npm packages shaped like that repo's `server/` and
# `client/`, and it is loaded into that repo's process. Until now it was the one
# part of the platform that had never been scanned — 75 files that arrived in the
# Phase 3 extraction with core's Sonar history left behind in core's project.
#
# Prerequisites (one-time, in the Gitea UI — Repo → Settings → Actions):
# • Secret SONAR_TOKEN — a SonarQube "Analysis" token generated at
# My Account → Security in SonarQube for the
# Module-uo project (or a global one).
# • Variable SONAR_HOST_URL — the SonarQube base URL on your LAN, e.g.
# http://192.168.0.56:9000
# (kept as a variable, not committed, so the internal address stays out of git.)
#
# The runner (self-hosted `ubuntu-latest`, same as the other workflows) must be
# able to reach SONAR_HOST_URL on your network. Nothing here waits on the
# SonarQube Quality Gate, so a failing gate does not fail this job — check the
# dashboard when you want to.
name: SonarQube
on:
push:
branches: [main]
# Allow re-running the analysis on demand from the Actions tab.
workflow_dispatch: {}
concurrency:
group: sonarqube-${{ github.ref }}
cancel-in-progress: true
jobs:
analysis:
runs-on: ubuntu-latest
steps:
- name: Check out (full history for accurate new-code + blame)
uses: actions/checkout@v4
with:
# SonarQube uses git history to attribute issues to authors and to
# compute "new code". A shallow clone degrades both.
fetch-depth: 0
# Node 22, where pr-checks.yml pins 20: the built-in `lcov` coverage
# reporter this job depends on needs >= 22. The version that matters for
# correctness is the one in pr-checks.yml, which matches the core process
# this module is loaded into; nothing here ships.
- uses: actions/setup-node@v4
with:
node-version: 22
- name: Install deps for both halves
run: |
npm ci --prefix server
npm ci --prefix client
# The chunk has to exist before the client suite runs: build.test.js and
# registration.test.js read `client/dist/entry.js`, and both SKIP when
# there is no build. Run the other way round they skip silently and this
# job reports coverage for a suite that quietly asked less than it looks
# like it did — the same ordering pr-checks.yml calls load-bearing.
- name: Build the client chunk
run: npm run build --prefix client
# SonarQube runs static analysis only — it never executes the test suite,
# so we must produce the coverage report ourselves and hand it to the
# scanner (see sonar.javascript.lcov.reportPaths in sonar-project.properties).
#
# Both suites are invoked from the REPO ROOT rather than with `--prefix`,
# so the LCOV `SF:` paths come out repo-root-relative (`server/router/...`,
# `client/src/...`) and resolve against sonar.sources. That is also why the
# server suite's `--require` is spelled out here instead of reusing
# `npm test --prefix server`, whose path is relative to `server/`.
- name: Generate server test coverage (LCOV)
run: |
mkdir -p server/coverage
node --test --experimental-test-coverage \
--require ./server/test/_setup.js \
--test-reporter=spec --test-reporter-destination=stdout \
--test-reporter=lcov --test-reporter-destination=server/coverage/lcov.info \
--test-reporter=./scripts/sonar-test-reporter.mjs --test-reporter-destination=server/coverage/test-execution.xml \
server/test/*.test.js
- name: Generate client test coverage (LCOV)
run: |
mkdir -p client/coverage
node --test --experimental-test-coverage \
--test-reporter=spec --test-reporter-destination=stdout \
--test-reporter=lcov --test-reporter-destination=client/coverage/lcov.info \
--test-reporter=./scripts/sonar-test-reporter.mjs --test-reporter-destination=client/coverage/test-execution.xml \
client/test/*.test.js
- name: Run SonarQube scan
uses: sonarsource/sonarqube-scan-action@v4
env:
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
SONAR_HOST_URL: ${{ vars.SONAR_HOST_URL }}

View File

@@ -96,6 +96,19 @@ when someone builds on the server is not shippable.
branch and no cutover, unlike `website`, whose module work accumulates on `edge`
and reaches `main` once.
### Static analysis runs after the merge, not on the PR
`.gitea/workflows/sonarqube.yml` scans `main` on push and reports to the
self-hosted SonarQube instance under the project key **`Module-uo`**. It is
deliberately non-blocking: it never gates a pull request, and a failing quality
gate does not fail the job. Check the dashboard when you want to; the things
that must not reach `main` are gated by `pr-checks.yml` instead.
It runs both suites from the repo root to produce coverage, and builds the
client chunk first — two of the client tests read `dist/entry.js` and skip
without it, which would leave this job reporting on a suite that quietly asked
less than it appears to.
### Commit messages
We use [Conventional Commits](https://www.conventionalcommits.org/) —

View File

@@ -1,6 +1,6 @@
{
"$comment": "The core this module is proved against. MODULE_API.md §5.3: the frozen-manifest job clones RunicGateway/website at this exact ref, drops this module in as modules/uo and runs CORE's own routeManifest.js — nothing else can answer whether the URLs the module claims are the URLs it actually serves. Pinned rather than tracking `edge` on purpose: core moves for reasons that have nothing to do with this module, and a bump is then a deliberate commit saying which core the module was last proved against, instead of an unexplained red X on someone else's PR. Bump it, regenerate routes.manifest.json, and commit both together.",
"repo": "https://gitea.whitlocktech.com/RunicGateway/website.git",
"ref": "87230c879aa6e9adde3507718aed6bc4e4d86009",
"refName": "edge @ phase 3 slice 4 (website#140)"
"ref": "7ed2ac99838f4bd64e1df324fe4961673648b0e6",
"refName": "edge @ Teams phase 3 (website#152)"
}

View File

@@ -39,6 +39,7 @@ export const shard = {
champs: () => req('/public/shard/champs'),
// Protocol 2.0 boards.
guilds: () => req('/public/shard/guilds'),
guild: (id) => req(`/public/shard/guilds/${encodeURIComponent(id)}`),
governors: () => req('/public/shard/governors'),
governorHistory: (city, limit) =>
req(`/public/shard/governors/${encodeURIComponent(city)}/history${withQs(limit ? `limit=${limit}` : '')}`),

View File

@@ -48,7 +48,7 @@ if (createElement !== rg.react.createElement || createRoot !== rg.reactDom.creat
)
}
// The curated kit (§3.4). Seven members, closed: anything else this module needs
// The curated kit (§3.4). Eight members, closed: anything else this module needs
// it bundles itself, which is why `components/` next door exists at all.
export const {
PublicLayout,
@@ -59,6 +59,11 @@ export const {
useAsync,
useAuth,
useSite,
// Eighth member (MODULE_API 1.6.0): the slot renderer, for the INVERTED
// direction — this module declares a place on its own page and CORE fills it.
// Shared rather than reimplemented so core's content failing inside our page is
// contained by core's own error boundary.
Slot,
} = rg.ui
// The registry, for entry.jsx. Everything else here is read by pages.

View File

@@ -26,6 +26,7 @@ import Shard from './routes/public/Shard.jsx'
import ShardActivity from './routes/public/ShardActivity.jsx'
import ChampSpawns from './routes/public/ChampSpawns.jsx'
import Guilds from './routes/public/Guilds.jsx'
import Guild from './routes/public/Guild.jsx'
import Governors from './routes/public/Governors.jsx'
import Houses from './routes/public/Houses.jsx'
import Rules from './routes/public/Rules.jsx'
@@ -81,6 +82,7 @@ registry.registerRoutes(ID, {
{ path: 'shard/activity', element: <ShardActivity /> },
{ path: 'champs', element: <ChampSpawns /> },
{ path: 'guilds', element: <Guilds /> },
{ path: 'guilds/:id', element: <Guild /> },
{ path: 'governors', element: <Governors /> },
{ path: 'houses', element: <Houses /> },
{ path: 'rules', element: <Rules /> },
@@ -180,6 +182,32 @@ registry.registerFeatureProvider(ID, ID, useShardFlags)
registry.registerExtension(ID, 'site.footer.status', ShardStatusLink)
registry.registerExtension(ID, 'admin.users.detail', UserShardSections)
registry.registerExtension(ID, 'player.invite.accepted', InviteGameAccountStep)
// ── The inverted slot: this module DECLARES, core fills ────────────────────
//
// The other three above are core's slots that this module fills. This one is the
// reverse (TEAMS.md Part 3): Teams are a core primitive that this module
// populates, but core does not own the word "guild" and publishes no Team page of
// its own — so the page is ours and core contributes the activity feed to it.
//
// Declared under this module's own namespace, which core enforces. Core's fill is
// applied after every module chunk has evaluated, so declaring it here is early
// enough; on a core that knows nothing of Teams it simply stays empty.
registry.declareModuleSlot(ID, 'uo.guild.detail')
// A SECOND place on the same page, for core's Team forum (TEAMS.md Part 5). Two
// declarations rather than one, because a slot holds one component and this module
// wants to decide where each of core's two contributions sits on its own page —
// the feed reads as part of the guild's story, the forum is a room you go into.
// Neither knows the other exists, and a core that fills only one leaves the other
// empty.
registry.declareModuleSlot(ID, 'uo.guild.forum')
// And a THIRD, at the top of the same page, for core's per-Team notification
// control (TEAMS.md §6.3). Same reasoning as the other two and a different place:
// muting a guild is an action ON this page, so it sits with the page's heading
// rather than after its content. Core resolves whether this viewer is in the
// Team at all — this module neither knows nor asks.
registry.declareModuleSlot(ID, 'uo.guild.header')
// `module.json`'s `coreApi` range is checked by the loader before this file is
// ever served, so there is nothing to re-check here. It is logged because a

View File

@@ -0,0 +1,122 @@
import { useParams, Link } from 'react-router-dom'
import api from '../../api.js'
import { ErrorState, Loading, PageHeader, PublicLayout, Slot, useAsync } from '../../core.js'
// One guild: its roster, and the place core puts the Team activity feed.
//
// **This page is the reason the extension-slot direction inverts**
// (docs/website/TEAMS.md Part 3). Teams are a core platform primitive and this
// module is what populates them — but core does not own the word "guild", so it
// publishes no Team page of its own. The page is this module's; the activity feed
// on it is core's, because only core can resolve whether the viewer is inside the
// Team, and the public/members split on that feed is a security boundary.
//
// So the module declares `uo.guild.detail` (entry.jsx) and core fills it. On a
// core that does not know about Teams the slot is simply never filled and this
// page renders its roster alone, which is the same tolerance every other slot has.
//
// The roster comes from this module's OWN board — the same data it answers core's
// Team provider from — rather than from core's Team API. That is deliberate: the
// board is the authoritative copy here, and reading core's projection of our own
// answer back would be a round trip through a staler copy of our own data.
function rankOf(m) {
// Absent rank means NOT KNOWN, never rank 0. The bridge omits it entirely for
// staff, because ServUO reports GameMaster-and-above as Leader whatever their
// real rank — emitting that verbatim would publish every staff member in a
// guild as one of its leaders (docs/link/v4.md).
if (m.rankName) return m.rankName
return null
}
function MemberRow({ m }) {
const rank = rankOf(m)
const linked = m.webId != null || m.acct != null
return (
<tr style={{ borderTop: '1px solid var(--line)' }}>
<td style={{ padding: '9px 10px', color: 'var(--head)' }}>
{m.name || 'Unknown'}
{m.rank === 4 && (
<span className="sans" style={{ color: 'var(--accent)', marginLeft: 8, fontSize: '0.72rem' }}>Leader</span>
)}
</td>
<td className="sans dim" style={{ padding: '9px 10px', fontSize: '0.86rem' }}>{rank || '—'}</td>
<td className="sans dim" style={{ padding: '9px 10px', fontSize: '0.86rem' }}>
{linked ? 'Linked' : '—'}
</td>
</tr>
)
}
export default function Guild() {
const { id } = useParams()
const { loading, error, data } = useAsync(() => api.shard.guild(id), [id])
const roster = (data && data.roster) || []
return (
<PublicLayout section="website">
<div className="shell-narrow page-body">
<p style={{ marginBottom: 14 }}>
<Link to="/uo/guilds">← All guilds</Link>
</p>
{loading && <Loading />}
{error && <ErrorState message="Could not load this guild right now." />}
{!loading && !error && data && (
<>
<PageHeader
eyebrow={data.abbr ? `[${data.abbr}]` : 'Guild'}
title={data.name || 'A guild'}
/>
<p className="sans dim" style={{ fontSize: '0.88rem' }}>
{data.members ?? roster.length} members
{data.online != null && ` · ${data.online} online`}
{data.alliance && ` · ${data.alliance}`}
</p>
{/* A third place for core, up here rather than below the roster: core
puts this guild's notification control in it, and a control that
acts on the page belongs beside the page's title and not after its
content. Empty for a visitor with no membership, and on a core
that fills nothing. */}
<Slot name="uo.guild.header" externalId={String(id)} moduleId="uo" />
{roster.length > 0 && (
<div style={{ overflowX: 'auto', marginTop: 18 }}>
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<thead>
<tr className="sans dim" style={{ textAlign: 'left', fontSize: '0.72rem', textTransform: 'uppercase', letterSpacing: '0.06em' }}>
<th style={{ padding: '8px 10px' }}>Name</th>
<th style={{ padding: '8px 10px' }}>Rank</th>
<th style={{ padding: '8px 10px' }}>Account</th>
</tr>
</thead>
<tbody>
{/* Keyed by serial: two characters can share a display name,
which this shard's own world actually contains. */}
{roster.map((m) => <MemberRow key={m.serial} m={m} />)}
</tbody>
</table>
</div>
)}
{roster.length === 0 && (
<p className="sans dim" style={{ marginTop: 18 }}>No roster has been received for this guild yet.</p>
)}
{/* Core's Team activity feed lands here. Nothing renders on a core
that does not fill it, or when there is nothing to show. The guild
is named in OUR terms — core maps its own Team from these two. */}
<Slot name="uo.guild.detail" externalId={String(id)} moduleId="uo" />
{/* And the Team forum, in its own place below the feed. Core resolves
who may read it — membership and manual grants are core's rules —
so this module renders the room and never its door policy. */}
<Slot name="uo.guild.forum" externalId={String(id)} moduleId="uo" />
</>
)}
</div>
</PublicLayout>
)
}

View File

@@ -1,4 +1,5 @@
import { useMemo, useState } from 'react'
import { Link } from 'react-router-dom'
import { useShardFeed } from '../../lib/useShardFeed.js'
import api from '../../api.js'
import { ErrorState, Loading, PageHeader, PublicLayout, useAsync } from '../../core.js'
@@ -15,9 +16,12 @@ function Leader({ leader }) {
function GuildRow({ g }) {
return (
<div
// A link now, because the board gained a detail page: the roster and core's
// Team activity feed live there (docs/website/TEAMS.md Part 3).
<Link
to={`/uo/guilds/${encodeURIComponent(g.id)}`}
className="panel"
style={{ padding: '14px 16px', display: 'flex', alignItems: 'center', gap: 14 }}
style={{ padding: '14px 16px', display: 'flex', alignItems: 'center', gap: 14, textDecoration: 'none' }}
>
<div style={{ minWidth: 0, flex: 1 }}>
<div style={{ display: 'flex', alignItems: 'baseline', gap: 8, minWidth: 0 }}>
@@ -59,7 +63,7 @@ function GuildRow({ g }) {
<Leader leader={g.leader} />
</div>
</div>
</div>
</Link>
)
}

View File

@@ -44,6 +44,7 @@ function fakeRg() {
const nav = { public: [], admin: [], player: [] }
const providers = new Map()
const extensions = new Map()
const declaredSlots = new Set()
return {
version: '1.3.0',
react,
@@ -54,7 +55,7 @@ function fakeRg() {
// 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 +73,19 @@ function fakeRg() {
if (extensions.has(slot)) throw new Error(`slot "${slot}" already filled`)
extensions.set(slot, { id, Component })
},
// The INVERTED direction (core API 1.6.0): this module declares a place on
// its OWN page and core fills it. Core enforces the namespace, so the fake
// does too — a chunk that declared an unnamespaced slot would pass here and
// throw in a browser.
declareModuleSlot(id, name) {
if (!name.startsWith(`${id}.`)) throw new Error(`declareModuleSlot: "${name}" must be namespaced "${id}."`)
if (declaredSlots.has(name)) throw new Error(`extension slot "${name}" already declared`)
declaredSlots.add(name)
},
routesFor: (area) => routes[area],
navFor: (area) => nav[area],
},
_read: () => ({ routes, nav, providers, extensions }),
_read: () => ({ routes, nav, providers, extensions, declaredSlots }),
}
}
@@ -98,7 +108,7 @@ const it = (name, fn) => test(name, { skip: skip && 'no dist/entry.js — run np
it('registers routes in all three areas, namespaced under the module id', () => {
const { routes } = registered
assert.equal(routes.public.length, 12)
assert.equal(routes.public.length, 13)
assert.equal(routes.admin.length, 7)
assert.equal(routes.player.length, 2)
for (const area of ['public', 'admin', 'player']) {
@@ -166,7 +176,7 @@ it('a nav row that gates on a feature is gated by a namespace this module provid
assert.ok(registered.providers.has('uo'), 'rows carry feature gates but no provider was registered')
})
it('fills the three extension slots, each with a component', () => {
it('fills the three CORE extension slots, each with a component', () => {
const { extensions } = registered
assert.deepEqual(
[...extensions.keys()].sort(),
@@ -198,3 +208,29 @@ it('registers under exactly one module id, matching the manifest', () => {
])
assert.deepEqual([...owners], [manifest.id])
})
it('declares its own guild slots, for core to fill', () => {
// The inverted direction (TEAMS.md Part 3). Teams are a core primitive with no
// core page: core owns the activity feed and the forum, this module owns the
// word "guild", so this module declares the places and core puts them in.
//
// THREE slots rather than one because a slot holds one component: stacking the
// feed, the forum and the notification control into a single fill would take
// away this module's ability to place them separately on its own page — and it
// does place them separately, the control above the roster and the other two
// below it.
assert.deepEqual([...registered.declaredSlots], [
'uo.guild.detail',
'uo.guild.forum',
'uo.guild.header',
])
})
it('every declared slot is rendered by the page that owns it', () => {
// A slot nothing renders is a slot core fills into the void. Asserted against
// the source rather than the chunk, since the chunk is minified.
const page = fs.readFileSync(path.resolve(HERE, '..', 'src', 'routes', 'public', 'Guild.jsx'), 'utf8')
for (const name of registered.declaredSlots) {
assert.match(page, new RegExp(`name="${name.replace(/\./g, '\.')}"`))
}
})

View File

@@ -201,6 +201,11 @@
"path": "/api/v1/public/shard/guilds",
"tier": "public"
},
{
"method": "GET",
"path": "/api/v1/public/shard/guilds/:id",
"tier": "public"
},
{
"method": "GET",
"path": "/api/v1/public/shard/houses",

View File

@@ -0,0 +1,64 @@
// Custom node:test reporter that emits SonarQube's Generic Test Execution XML.
//
// Node's built-in reporters give us coverage (`lcov`) and pass/fail output
// (`spec`/`tap`/`junit`), but SonarQube's "Unit Tests" measure is fed by a
// SEPARATE report in *its own* format via `sonar.testExecutionReportPaths` — the
// lcov report only populates Coverage, which is why the dashboard shows coverage
// while the Unit Tests tile stays "-". This reporter produces that missing report.
//
// Format: https://docs.sonarsource.com/sonarqube/latest/analyzing-source-code/test-coverage/generic-test-data/
// <testExecutions version="1">
// <file path="server/test/foo.test.js">
// <testCase name="..." duration="12"/> <!-- duration = integer ms -->
// </file>
// </testExecutions>
//
// Paths are emitted repo-root-relative (POSIX separators) so they match the
// `sonar.tests` roots; the workflow runs `node --test` from the repo root, so the
// absolute `file` on each event strips cleanly against process.cwd().
import path from 'node:path'
function xmlEscape(s) {
return String(s).replace(/[<>&"']/g, (c) => ({
'<': '&lt;',
'>': '&gt;',
'&': '&amp;',
'"': '&quot;',
"'": '&apos;',
})[c])
}
export default async function* sonarTestReporter(source) {
const byFile = new Map()
const cwd = process.cwd()
for await (const event of source) {
if (event.type !== 'test:pass' && event.type !== 'test:fail') continue
const d = event.data
// Skip the container events (a `describe` suite) and anything without a file
// — only real test cases go in the report, so the count matches the runner's.
if (!d.file || (d.details && d.details.type === 'suite')) continue
const rel = path.relative(cwd, d.file).split(path.sep).join('/')
if (!byFile.has(rel)) byFile.set(rel, [])
byFile.get(rel).push({
name: d.name,
duration: Math.max(0, Math.round(d.details?.duration_ms ?? 0)),
failed: event.type === 'test:fail',
skipped: Boolean(d.skip || d.todo),
})
}
yield '<?xml version="1.0" encoding="UTF-8"?>\n<testExecutions version="1">\n'
for (const [file, cases] of byFile) {
yield ` <file path="${xmlEscape(file)}">\n`
for (const c of cases) {
const attrs = `name="${xmlEscape(c.name)}" duration="${c.duration}"`
if (c.failed) yield ` <testCase ${attrs}><failure message="test failed"/></testCase>\n`
else if (c.skipped) yield ` <testCase ${attrs}><skipped/></testCase>\n`
else yield ` <testCase ${attrs}/>\n`
}
yield ' </file>\n'
}
yield '</testExecutions>\n'
}

View File

@@ -45,6 +45,7 @@ DROP TABLE IF EXISTS `shard_ruleset`;
DROP TABLE IF EXISTS `shard_presence`;
DROP TABLE IF EXISTS `shard_governor_terms`;
DROP TABLE IF EXISTS `shard_governors`;
DROP TABLE IF EXISTS `shard_guild_members`;
DROP TABLE IF EXISTS `shard_guilds`;
DROP TABLE IF EXISTS `shard_pages`;
DROP TABLE IF EXISTS `shard_champs`;

View File

@@ -220,6 +220,52 @@ CREATE TABLE IF NOT EXISTS shard_guilds (
INDEX idx_shard_guilds_name (name)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- Guild membership (Protocol 4). One row per member per guild, replaced on
-- guild.roster and thinned by guild.leave. Protocol 2 could only say HOW MANY
-- members a guild had, so this table has no pre-4 equivalent and the Guilds page
-- could show a count but never a roster.
--
-- `acct` / `web_id` are the site-identity fields and are stored because the
-- sidecar forwards them; they are NOT public. shardVisibility locks any key that
-- is or ends in acct/webId to `admin` and recurses into arrays, so a projected
-- roster loses them below that rung — storing them here is what lets a linked
-- member be matched to a site user at all.
--
-- A roster over the shard's per-frame cap arrives in several frames, so rows are
-- keyed on (guild_id, serial) and the frame carrying seq 0 clears the guild first;
-- see upsertGuildRoster.
CREATE TABLE IF NOT EXISTS shard_guild_members (
guild_id INT NOT NULL,
serial VARCHAR(20) NOT NULL, -- in-game mobile serial, "0x1F5"
name VARCHAR(120) NULL,
acct VARCHAR(120) NULL, -- absent for a mobile with no account
web_id INT NULL, -- set only when the account is linked
is_player TINYINT(1) NOT NULL DEFAULT 1,
-- Guild rank, 0-4, with 4 being Leader (ServUO RankDefinition.Ranks). NULL means
-- "not known", which is a real state and not a demotion: the shard omits the rank
-- for a staff account, because PlayerMobile.GuildRank reports Leader for anyone at
-- GameMaster or above whatever their actual rank, and publishing that would put a
-- staff member on a public roster as a guild leader.
-- Backticked, like `int` on shard_online: RANK is a reserved word in MySQL 8 and
-- a non-reserved keyword in MariaDB, so it parses here bare but must not be
-- written that way anywhere it might not.
`rank` TINYINT NULL,
-- The rank's NAME, as the game states it: a cliloc id for the five standard ranks
-- (1062959-1062963, which ship with no text), or a literal string when a shard has
-- replaced the rank table with custom definitions. Resolving one to a label is this
-- module's job -- it owns the cliloc table and the game vocabulary.
rank_cliloc INT NULL,
rank_name VARCHAR(64) NULL,
t BIGINT NULL, -- roster event time, epoch ms
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (guild_id, serial),
INDEX idx_shard_guild_members_acct (acct),
INDEX idx_shard_guild_members_web (web_id),
-- Leadership is "rank >= 4", asked per guild, which is the query the Team provider
-- runs on every reconcile.
INDEX idx_shard_guild_members_rank (guild_id, rank)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- Town-governor board (Protocol 2.0, City Loyalty). One row per city, upserted on
-- city.update (full-state, emitted only on change; there is no remove event since
-- the set of cities is fixed). governor / governorElect are actor objects
@@ -641,4 +687,14 @@ INSERT IGNORE INTO settings (`key`, value) VALUES ('uo_link_protocol_3_migrated'
-- only a database that has never seen the key gets the default. Nothing in core
-- reads either one; `game_account_signup` is read through ctx.settings by
-- server/utils/gameSignup.js, which owns the policy.
INSERT IGNORE INTO settings (`key`, value) VALUES ('game_account_signup', 'disabled');
INSERT IGNORE INTO settings (`key`, value) VALUES ('game_account_signup', 'disabled');
-- Protocol 4 guild rank, added to databases that already have shard_guild_members.
--
-- The table itself is new in Protocol 4 and unreleased, so no production install has
-- it — but `edge` deployments do, from the roster work that landed before the rank
-- amendment, and CREATE TABLE IF NOT EXISTS adds a table and never a column. This is
-- the same gap the sidecar's own store hit when `guilds.members` was added.
ALTER TABLE shard_guild_members ADD COLUMN IF NOT EXISTS `rank` TINYINT NULL;
ALTER TABLE shard_guild_members ADD COLUMN IF NOT EXISTS rank_cliloc INT NULL;
ALTER TABLE shard_guild_members ADD COLUMN IF NOT EXISTS rank_name VARCHAR(64) NULL;
ALTER TABLE shard_guild_members ADD INDEX IF NOT EXISTS idx_shard_guild_members_rank (guild_id, `rank`);

View File

@@ -45,6 +45,7 @@ module.exports = function register(ctx, api) {
const shardStreams = require('./config/shardStreams')
const townCrierLeg = require('./utils/shardAnnounce')
const teamProvider = require('./model/teamProvider/teamProvider.model')
const boot = require('./boot')
/* eslint-enable global-require */
@@ -86,6 +87,15 @@ module.exports = function register(ctx, api) {
api.registerNotificationStreams(shardStreams.STREAMS)
api.registerAnnounceLeg(townCrierLeg.leg)
// Teams: a UO guild is a Team, and this module is the authoritative source of
// them for this deployment (MODULE_API 1.6.0). Core asks the three questions;
// everything about what a guild IS stays here.
//
// Registration is a claim, not a call — nothing below runs until core
// reconciles, which is after `onBoot`. That matters because every method reads
// the database, and registration must not.
api.registerTeamProvider(teamProvider)
api.onBoot(boot.onBoot)
api.onShutdown(boot.onShutdown)

View File

@@ -171,6 +171,50 @@ const removeGuild = (id) => query('DELETE FROM shard_guilds WHERE id = ?', [id])
const clearGuilds = () => query('DELETE FROM shard_guilds')
const listGuilds = () => query(`SELECT ${GUILD_COLS} FROM shard_guilds ORDER BY name ASC`)
// ── Guild membership (Protocol 4) ──────────────────────────────────────────
// `rank` is backticked wherever it is written, like `int` on shard_online: it is a
// reserved word in MySQL 8 and merely a keyword in MariaDB, so it parses bare here
// and must not be relied on to.
const MEMBER_COLS = 'guild_id, serial, name, acct, web_id, is_player, `rank`, rank_cliloc, rank_name, t'
// Upsert rather than plain insert: a roster frame can be redelivered (the /history
// backfill replays stored frames on every reconnect), and a redelivery must be a
// no-op rather than a duplicate-key error.
//
// The rank columns are assigned unconditionally, NULL included. A member whose rank
// the shard withheld — a staff account, whose GuildRank getter reports Leader
// regardless of the truth — must go back to "not known" rather than keeping a rank
// from before they were promoted.
const upsertGuildMembers = (rows) => {
if (!rows.length) return Promise.resolve()
const values = rows.map(() => '(?, ?, ?, ?, ?, ?, ?, ?, ?, ?)').join(', ')
const params = rows.flatMap((r) => [
r.guild_id, r.serial, r.name, r.acct, r.web_id, r.is_player,
r.rank, r.rank_cliloc, r.rank_name, r.t,
])
return query(
`INSERT INTO shard_guild_members (${MEMBER_COLS}) VALUES ${values}
ON DUPLICATE KEY UPDATE name = VALUES(name), acct = VALUES(acct),
web_id = VALUES(web_id), is_player = VALUES(is_player),
\`rank\` = VALUES(\`rank\`), rank_cliloc = VALUES(rank_cliloc),
rank_name = VALUES(rank_name), t = VALUES(t)`,
params,
)
}
const clearGuildMembers = (guildId) =>
query('DELETE FROM shard_guild_members WHERE guild_id = ?', [guildId])
const removeGuildMember = (guildId, serial) =>
query('DELETE FROM shard_guild_members WHERE guild_id = ? AND serial = ?', [guildId, serial])
const clearAllGuildMembers = () => query('DELETE FROM shard_guild_members')
const listGuildMembers = (guildId) =>
query(`SELECT ${MEMBER_COLS} FROM shard_guild_members WHERE guild_id = ? ORDER BY name ASC`, [
guildId,
])
// The guild an actor LEADS — matched on the current board (leader_serial or the
// linked leader_acct), so it reflects live state. Guild MEMBERSHIP for non-leaders
// is not modelled (the board carries only counts + leader), so we don't guess it.
@@ -342,6 +386,11 @@ module.exports = {
removeGuild,
clearGuilds,
listGuilds,
upsertGuildMembers,
clearGuildMembers,
removeGuildMember,
clearAllGuildMembers,
listGuildMembers,
findGuildLedByActor,
listGuildsLedByAccounts,
upsertGovernor,

View File

@@ -340,8 +340,88 @@ async function upsertGuild(ev) {
})
}
const removeGuild = (id) => (id == null ? Promise.resolve() : db.removeGuild(id))
const clearGuilds = () => db.clearGuilds()
const removeGuild = async (id) => {
if (id == null) return
await db.removeGuild(id)
await db.clearGuildMembers(id)
}
const clearGuilds = async () => {
await db.clearGuilds()
await db.clearAllGuildMembers()
}
// ── Guild membership (Protocol 4) ──────────────────────────────────────────
// Apply one guild.roster frame.
//
// A roster larger than the shard's per-frame cap arrives as several frames
// carrying seq/more/total. The sidecar reassembles them for its OWN board, but the
// live WebSocket feed and the /history backfill both carry the individual frames,
// so this ingest sees them unreassembled and has to cope.
//
// It copes without buffering, because a table can express what a single JSON column
// could not: the frame carrying seq 0 clears the guild first and every frame then
// upserts its own rows. Rows are keyed on (guild_id, serial), so a redelivered frame
// — the /history backfill replays stored frames on every reconnect — is idempotent
// rather than a duplicate-key error.
//
// The cost is a brief window during a multi-frame update where the table holds part
// of a roster. That is acceptable for a projection that is already only as fresh as
// a 60s sweep, and the frames arrive back-to-back in one burst; buffering to close
// it would duplicate the sidecar's reassembly for a sub-second inconsistency.
async function upsertGuildRoster(ev) {
if (!ev || ev.id == null) return
const seq = Number.isFinite(ev.seq) ? ev.seq : 0
const members = Array.isArray(ev.members) ? ev.members : []
// seq 0 begins a roster and supersedes whatever was held for this guild.
if (seq === 0) await db.clearGuildMembers(ev.id)
const rows = members
.filter((m) => m && m.serial)
.map((m) => ({
guild_id: ev.id,
serial: m.serial,
name: m.name ?? null,
acct: m.acct ?? null,
web_id: Number.isFinite(m.webId) ? m.webId : null,
is_player: m.player ? 1 : 0,
// Guild rank (Protocol 4). ABSENT is a real state and is stored as NULL: the
// shard withholds the rank for a staff account, because ServUO's GuildRank
// getter reports Leader for anyone at GameMaster or above whatever their
// actual rank. Defaulting a missing rank to 0 here would turn "we were not
// told" into "rank 0", which is a demotion invented by this line.
rank: Number.isInteger(m.rank) ? m.rank : null,
rank_cliloc: Number.isInteger(m.rankCliloc) ? m.rankCliloc : null,
rank_name: typeof m.rankName === 'string' && m.rankName ? m.rankName.slice(0, 64) : null,
t: Number.isFinite(ev.t) ? ev.t : null,
}))
await db.upsertGuildMembers(rows)
}
// A single departure (guild.leave). Advisory: the shard re-emits the full roster
// whenever the member set changes, so the table would converge on the next frame
// even if this were dropped. Applying it makes the change visible immediately
// instead of at the end of the sweep that produced it.
async function removeGuildMember(ev) {
if (!ev || ev.id == null || !ev.who) return
await db.removeGuildMember(ev.id, ev.who)
}
// The membership roster for one guild, in the wire shape the projection expects
// (an array of actor objects), so shardVisibility strips acct/webId by the same
// rule it applies to guild.leader.
async function listGuildMembers(guildId) {
const rows = await db.listGuildMembers(guildId)
return rows.map((r) => ({
serial: r.serial,
name: r.name,
...(r.acct == null ? {} : { acct: r.acct }),
...(r.web_id == null ? {} : { webId: r.web_id }),
player: !!r.is_player,
}))
}
function shapeGuild(r) {
const payload = typeof r.payload === 'string' ? safeJson(r.payload) : r.payload
@@ -620,6 +700,9 @@ module.exports = {
removeGuild,
clearGuilds,
listGuilds,
upsertGuildRoster,
removeGuildMember,
listGuildMembers,
replaceGuilds,
findGuildForActor,
listGuildsLedForAccounts,

View File

@@ -0,0 +1,87 @@
// SQL behind the Team provider — three questions core asks, answered from the
// guild board and the roster Protocol 4 put there.
//
// Every statement reads only THIS module's tables. Core's Team tables are
// core-internal (docs/website/TEAMS.md §10.3) and this module must never name
// one, even though it is what fills them.
// `query` is destructured from the core facade at require time, like every other
// *.db.js here. The facade resolves `ctx` per call, so taking it now is safe even
// though `ctx` does not exist yet when this file is first required.
const { query } = require('../../core')
/** ServUO's `RankDefinition.Ranks[4]` is Leader, and 4 is the top of the ladder. */
const LEADER_RANK = 4
/**
* The guild board — one row per guild the shard has told us about.
*
* `members`/`online` here are the COUNTS `guild.update` carries; the roster is a
* separate table (Protocol 4). Both are read, because a count is what the shard
* asserts and a roster is what it enumerated, and they can legitimately disagree
* for the moment between a membership change and the sweep that reports it.
*/
const listGuilds = () =>
query(
`SELECT id, name, abbr, alliance, members, online, leader_serial, leader_name, leader_acct
FROM shard_guilds ORDER BY name ASC`,
)
const findGuild = (id) =>
query(
`SELECT id, name, abbr, alliance, members, online, leader_serial, leader_name, leader_acct
FROM shard_guilds WHERE id = ? LIMIT 1`,
[id],
)
/**
* One guild's roster, with the site link and live presence folded in.
*
* Two LEFT JOINs, both deliberate:
*
* - `shard_account_links` resolves `user_id` HERE rather than in core, because
* this module owns that table and a core that read it would be core naming a
* module's table by name (§2.3). It is also why a freshly linked account
* appears as linked on the next reconcile rather than needing core to know
* anything about linking.
* - `shard_online` is how a member's `online` is answered at all. The roster
* frame does not carry it — the wire's member is the standard actor object
* (`serial`, `name`, `player`, `acct?`, `webId?`), and the board's `online` is
* a count, not a set. Presence therefore comes from the online table, which
* is the same source the public "who's online" surface already uses.
*
* `web_id` on the roster row is preferred over the link table when present: it is
* what the shard itself asserted at roster time, and the join is the fallback for
* a member whose row predates their link.
*/
const listGuildMembers = (guildId) =>
query(
"SELECT m.serial, m.name, m.acct, m.web_id, m.is_player, m.`rank`, m.rank_cliloc, m.rank_name, " +
` l.user_id AS linked_user_id,
(o.serial IS NOT NULL) AS is_online
FROM shard_guild_members m
LEFT JOIN shard_account_links l ON l.account = m.acct
LEFT JOIN shard_online o ON o.serial = m.serial
WHERE m.guild_id = ?
ORDER BY m.name ASC`,
[guildId],
)
/**
* Every member at leader rank — rank 4, the top of ServUO's `RankDefinition.Ranks`.
*
* A set, not a single row, and that is the whole reason Protocol 4 grew a per-member
* rank: the guild board carries one `leader_serial`, so before this the website could
* only ever be told about one leader, while a UO guild routinely has several.
*
* A NULL rank is excluded by the comparison, which is correct — the shard withholds
* the rank for a staff account rather than publishing the Leader its getter falsely
* reports, and "not known" must not be read as "leads this guild".
*/
const listGuildLeaders = (guildId) =>
query(
'SELECT serial FROM shard_guild_members WHERE guild_id = ? AND `rank` >= ? ORDER BY name ASC',
[guildId, LEADER_RANK],
)
module.exports = { listGuilds, findGuild, listGuildMembers, listGuildLeaders, LEADER_RANK }

View File

@@ -0,0 +1,334 @@
// ── module-uo's Team provider ──────────────────────────────────────────────
//
// The three questions core asks this module about Teams
// (docs/website/MODULE_API.md — `api.registerTeamProvider`, and TEAMS.md §2.3).
// A UO guild is a Team; this file is the whole of the translation.
//
// **Every method returns an envelope, and answering `{ ok: false }` is a normal
// outcome, not a failure to handle.** Core's contract is that module
// unavailability becomes staleness and never emptiness, and the only way this
// module can say "I cannot answer" is to say so — an empty array would be read as
// an authoritative "there are none", which during a cold start is how every
// roster on the site gets emptied. So the guard below is the most important code
// in the file, and it is deliberately conservative: **an unreachable or
// never-connected sidecar refuses, rather than reporting the board it happens to
// still hold.**
//
// The board IS durable and would survive a sidecar outage, which is exactly what
// makes this tempting to get wrong. The reason to refuse anyway: core cannot tell
// a board that is five minutes stale from one that is five days stale, and it
// makes destructive decisions — archiving Teams, departing members — from a
// complete answer. Reporting a stale board as authoritative would license those.
const core = require('../../core')
const db = require('./teamProvider.db')
const uoLinkConfig = require('../uoLinkConfig/uoLinkConfig.model')
const uoLinkSocket = require('../../utils/uoLinkSocket')
const clilocs = require('../shardClilocs/shardClilocs.model')
const visibility = require('../../utils/shardVisibility')
const log = core.logger('teams')
/**
* ServUO's five stock rank names, by the cliloc id the game names them with.
*
* A fallback, not the source of truth: the operator's own cliloc table is consulted
* first, and a shard with custom rank definitions sends a literal string that beats
* both. This exists because the cliloc table is populated only if someone ran the
* client-file extraction, and a roster on a shard that has not should still say
* "Warlord" rather than nothing.
*/
const STANDARD_RANK_NAMES = {
1062959: 'Leader',
1062960: 'Warlord',
1062961: 'Emissary',
1062962: 'Member',
1062963: 'Ronin',
}
/** A refusal, in the shape core reads (§2.3). */
const refuse = (reason) => ({ ok: false, reason })
/**
* Is the bridge in a state where the board can be trusted as current?
*
* The board is only as good as the socket that fills it. Three states refuse, and
* they are asked in this order because each is a different thing being wrong:
*
* - **no uo-link configured** — there is no shard behind this website at all;
* - **the integration is disabled** — an admin turned it off, and the board is
* frozen at whatever it held;
* - **the socket is not connected** — the board is a snapshot of unknown age.
*
* The in-process socket state is preferred over the persisted status column,
* which is written on transitions: a process that has just started has not
* transitioned yet, so the column can still say `connected` from the last run
* while this process has never opened a socket.
*/
async function boardIsCurrent() {
const config = await uoLinkConfig.getSafe()
if (!config || !config.baseUrl) return { ok: false, reason: 'no uo-link configured' }
if (!config.enabled) return { ok: false, reason: 'the uo-link integration is disabled' }
const state = uoLinkSocket.getState()
if (!state || !state.connected) {
return { ok: false, reason: 'the uo-link socket is not connected; the guild board may be stale' }
}
return { ok: true }
}
/**
* `getTeams()` — every guild on the board.
*
* `externalId` is the ServUO `Guild.Id`, which survives a rename: renaming a
* guild in-game keeps the id, so core sees "an id whose name changed" and applies
* its rename rule (archive plus create). That mapping is this module's to make —
* only the game knows what identity survives what (§10.5).
*
* `meta` carries the alliance, opaquely. Core stores and displays it and never
* branches on it, which is what lets a UO concept reach a Team page without core
* acquiring an opinion about alliances.
*/
async function getTeams() {
const ready = await boardIsCurrent()
if (!ready.ok) return refuse(ready.reason)
try {
const rows = await db.listGuilds()
return {
ok: true,
complete: true,
teams: rows.map((row) => ({
externalId: String(row.id),
name: row.name,
abbr: row.abbr || null,
meta: row.alliance ? { alliance: row.alliance } : null,
})),
}
} catch (err) {
log.warn('getTeams failed', { message: err.message })
return refuse(`guild board unreadable: ${err.message}`)
}
}
/**
* `getTeamMembers(externalId)` — one guild's roster.
*
* **A guild with no roster rows is refused, not reported empty**, unless the board
* itself says the guild has no members. Protocol 4's roster arrives on its own
* frames, separately from the `guild.update` that creates the board row, so there
* is a real window — a fresh guild, or a website that connected between the two —
* where core would otherwise be told authoritatively that a 155-member guild has
* nobody in it. The board's own `members` count is what distinguishes the two,
* and it is the only thing that can.
*/
async function getTeamMembers(externalId) {
const ready = await boardIsCurrent()
if (!ready.ok) return refuse(ready.reason)
try {
const [guild] = await db.findGuild(externalId)
if (!guild) return refuse(`guild ${externalId} is not on the board`)
const rows = await db.listGuildMembers(externalId)
if (!rows.length && guild.members > 0) {
return refuse(`roster for guild ${externalId} has not arrived yet (board says ${guild.members} members)`)
}
const labels = await rankLabels(rows)
return {
ok: true,
complete: true,
members: rows.map((row) => ({
memberKey: row.serial,
displayName: row.name || null,
rankLabel: labels.get(row.serial) || null,
// Rank 4 is Leader, and several members can hold it. A NULL rank is not a
// leader: the shard withholds the rank for a staff account rather than
// publishing the Leader its getter falsely reports, and "not known" must
// never be read as "leads this guild".
leader: Number.isInteger(row.rank) && row.rank >= db.LEADER_RANK,
online: Boolean(row.is_online),
userId: resolveUserId(row),
})),
}
} catch (err) {
log.warn('getTeamMembers failed', { externalId, message: err.message })
return refuse(`roster unreadable: ${err.message}`)
}
}
/**
* `getTeamLeaders(externalId)` — everyone at leader rank.
*
* **All of them, which is why Protocol 4 grew a per-member rank.** The guild board
* carries one `leader_serial`, so before the rank amendment this could only ever
* name a single member, while a UO guild routinely has several at rank 4 and
* TEAMS.md §2.5 treats multiple leaders as the normal case.
*
* The board's own `leader_serial` is folded in as a floor. It is the guild's
* founder-leader and it comes from a different frame (`guild.update`), so on a
* shard whose roster has not been re-emitted since the amendment it is the only
* leadership signal there is — and it should never be *lost* by moving to ranks.
*/
async function getTeamLeaders(externalId) {
const ready = await boardIsCurrent()
if (!ready.ok) return refuse(ready.reason)
try {
const [guild] = await db.findGuild(externalId)
if (!guild) return refuse(`guild ${externalId} is not on the board`)
const rows = await db.listGuildLeaders(externalId)
const leaders = rows.map((r) => r.serial)
if (guild.leader_serial && !leaders.includes(guild.leader_serial)) {
leaders.push(guild.leader_serial)
}
return { ok: true, leaders }
} catch (err) {
log.warn('getTeamLeaders failed', { externalId, message: err.message })
return refuse(`leadership unreadable: ${err.message}`)
}
}
/**
* Resolve each member's rank to a display label, keyed by serial.
*
* The shard sends the rank's NAME as the game states it — a cliloc id for the five
* standard ranks, or a literal string for a custom rank definition — and never a
* resolved label, because ServUO ships no text for those clilocs. This module does
* have a cliloc table, which is why the resolution belongs here.
*
* Three sources, in order: a custom string wins, then the operator's cliloc table,
* then the five standard names. The last exists because the cliloc table is
* populated only if someone ran the client extraction, and a shard that has not
* should still read "Warlord" rather than nothing.
*
* Never throws: a rank label is decoration on a roster, and a lookup failure must
* not turn a good roster into a refusal.
*/
async function rankLabels(rows) {
const out = new Map()
const wanted = []
for (const row of rows) {
if (row.rank_name) {
out.set(row.serial, row.rank_name)
} else if (Number.isInteger(row.rank_cliloc)) {
wanted.push(row.rank_cliloc)
}
}
let resolved = new Map()
if (wanted.length) {
try {
resolved = await clilocs.resolveMany(wanted)
} catch (err) {
log.warn('rank cliloc lookup failed; falling back to the standard names', { message: err.message })
}
}
for (const row of rows) {
if (out.has(row.serial) || !Number.isInteger(row.rank_cliloc)) continue
const label = resolved.get(row.rank_cliloc) || STANDARD_RANK_NAMES[row.rank_cliloc] || null
if (label) out.set(row.serial, label)
}
return out
}
/**
* The site account behind a character, or null.
*
* `web_id` is what the shard itself asserted when it emitted the roster; the
* account-link join is the fallback for a member whose roster row predates their
* link. Both are coerced through the same check, because `web_id` arrives from
* the wire as a string.
*/
function resolveUserId(row) {
const fromRoster = Number.parseInt(row.web_id, 10)
if (Number.isInteger(fromRoster) && fromRoster > 0) return fromRoster
const fromLink = Number.parseInt(row.linked_user_id, 10)
return Number.isInteger(fromLink) && fromLink > 0 ? fromLink : null
}
/**
* Which roster rows a viewer may see (TEAMS.md §3.3, MODULE_API 1.6.0).
*
* The optional fourth provider method, and the only one core calls on a REQUEST
* path rather than from the reconciler. Core holds the roster and its public
* shape; the question that is this module's is "who is allowed to look", because
* the audience rungs and their configuration live here (`utils/shardVisibility`)
* and core does not know what a rung is.
*
* **The answer is all-or-nothing, and that is correct rather than a shortcut.**
* A rung is a property of the FEATURE, not of a member: `guilds` is either
* visible to this viewer or it is not, and there is no configuration in which
* some members of a guild are public and others are not. Returning every key or
* none is the honest translation of the model this module actually has.
*
* **A refusal here costs visibility, not staleness.** Core fails closed on this
* one call — an unanswered visibility question serves an empty roster rather than
* an unprojected one — so every path below that cannot reach a confident answer
* refuses deliberately, and the catch does too. That is the opposite of the rule
* governing the other three methods, and it is the right way round: for a roster
* SYNC an unanswered call must change nothing, and for a roster READ it must
* publish nothing.
*
* Note what this does NOT do: strip fields. `acct` and `webId` are the leak this
* module's projection exists to prevent on the live feed, and neither is in
* core's roster shape at all — core withholds the member key and the site account
* id from every public roster whatever this returns. So there is nothing here to
* redact, only rows to withhold.
*/
async function projectRoster(externalId, members, viewer) {
try {
const config = await visibility.getConfig()
const feature = config.guilds
// An admin turned guilds off. Nobody sees a roster, including staff — the
// switch means "this shard does not publish guild data", not "publish it
// quietly".
if (!feature || !feature.enabled) return { ok: true, members: [] }
// `viewerLevel` reads a REQUEST; core hands over a described viewer instead,
// which is deliberate — it keeps the `users` row out of the contract.
//
// The no-viewer case is answered here rather than by handing `viewerLevel` an
// empty object: given a request with no `req.user` it falls through to
// `auth.getUserFromRequest`, which expects real cookies and headers and
// throws on a synthetic one. That throw would land in the catch below and
// become a REFUSAL, so every anonymous visitor would have been served an
// empty roster on a shard whose guilds are public. Anonymous is a known
// answer, not a failed lookup.
const level = viewer
? await visibility.viewerLevel({ user: { id: viewer.userId, role: viewer.role } })
: 'anonymous'
if (!visibility.meets(level, feature.audience)) return { ok: true, members: [] }
return { ok: true, members: members.map((m) => m.member_key).filter(Boolean) }
} catch (err) {
// Core reads this as "withhold the roster". Saying so is the whole point: the
// alternative — answering with every key because the config read failed —
// 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 guild (MODULE_API 1.6.0, TEAMS.md §6.4).
//
// **Core cannot work this out for itself, and it is not supposed to.** Teams are
// a contract primitive with no core surface — this module owns the guild page,
// because core does not own the word "guild" — so the one thing core needs back
// is where the page it does not own actually lives. A notification email that
// cannot link to the thread it is about is most of the way to useless.
//
// A relative path with `{externalId}` substituted, matching `Guild.jsx`'s route
// (`/uo/guilds/:id`). Core does the substitution and nothing else with it; a
// template naming its own host is refused at registration, which is why this is
// data and not a callback.
const pageUrlTemplate = '/uo/guilds/{externalId}'
module.exports = { getTeams, getTeamMembers, getTeamLeaders, projectRoster, boardIsCurrent, pageUrlTemplate }

View File

@@ -177,6 +177,31 @@ async function getGuilds(req, res) {
}
}
// GET /public/shard/guilds/:id — one guild and its roster.
//
// The board endpoint above returns every guild WITHOUT its roster; this is the
// detail view, and it is the page that hosts core's Team activity feed through
// the `uo.guild.detail` slot (docs/website/TEAMS.md Part 3).
//
// Projected through the same `guilds` feature as the board, so an operator who
// gates guilds to staff gates this too, and `acct`/`webId` on the roster rows
// never survive below admin — those are LOCKED fields, and a roster is where they
// actually appear in bulk.
async function getGuild(req, res) {
try {
const guilds = await shardState.listGuilds()
const guild = guilds.find((g) => String(g.id) === String(req.params.id))
// 404 rather than an empty object: a guild that disbanded is gone, and the
// page needs to say so rather than render an empty shell.
if (!guild) return res.status(404).json({ message: 'Not Found' })
const members = await shardState.listGuildMembers(guild.id)
return res.json(await visibility.project('guilds', { ...guild, roster: members }, req))
} catch (err) {
log.error('shard.getGuild', err)
return res.status(500).json({ message: 'Internal Server Error' })
}
}
// GET /public/shard/governors — the current town-governor board (empty on shards
// without City Loyalty). Live via city.update on the public SSE stream. Projected
// for the same reason as getGuilds: `governor` / `governorElect` are actors.
@@ -421,6 +446,7 @@ module.exports = {
getIdoc,
getChamps,
getGuilds,
getGuild,
getGovernors,
getGovernorHistory,
getPresence,

View File

@@ -100,6 +100,17 @@ shardRouter.get(
/* #swagger.responses[200] = { description: 'Guilds, ordered by name', content: { "application/json": { schema: { type: "array", items: { type: "object", additionalProperties: true } } } } } */
shard.getGuilds,
)
shardRouter.get(
'/guilds/:id',
requireFeature('guilds'),
// #swagger.tags = ['Public · Shard']
// #swagger.summary = 'One guild and its roster'
// #swagger.description = 'The detail view behind the board. Gated and projected through the same `guilds` feature, so an operator who raises that audience raises this too, and the locked acct/webId fields never survive below admin — a roster is where they appear in bulk. This page is also where core renders the Team activity feed, through the `uo.guild.detail` extension slot.'
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'The guild id.' }
/* #swagger.responses[200] = { description: 'The guild, with its roster', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */
/* #swagger.responses[404] = { description: 'No such guild', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */
shard.getGuild,
)
shardRouter.get(
'/governors',
requireFeature('governors'),

View File

@@ -95,6 +95,7 @@ function fakeApi() {
extensions: [],
streams: null,
legs: [],
teamProvider: null,
hooks: {},
}
const called = new Set()
@@ -107,6 +108,10 @@ function fakeApi() {
registerExtension(slot, router) { record.extensions.push({ slot, router }) },
registerNotificationStreams(streams) { once('registerNotificationStreams'); record.streams = streams },
registerAnnounceLeg(leg) { record.legs.push(leg) },
// MODULE_API 1.6.0. `once` because core holds a single provider per
// deployment — a second registration is a collision there, so it has to be
// one here too, or this suite would pass a shape core rejects at load.
registerTeamProvider(provider) { once('registerTeamProvider'); record.teamProvider = provider },
onBoot(fn) { once('onBoot'); record.hooks.onBoot = fn },
onShutdown(fn) { once('onShutdown'); record.hooks.onShutdown = fn },
}

View File

@@ -85,6 +85,35 @@ test('every registered stream is namespaced or grandfathered', () => {
}
})
test('registers a Team provider with all three methods', () => {
// Core requires all three: 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. Asserted here so a refactor that drops one
// fails in this suite rather than at load on an operator's install.
const api = fakeApi()
register(fakeCtx(), api)
const provider = api.record.teamProvider
assert.ok(provider, 'a UO guild is a Team; something has to answer for them')
for (const method of ['getTeams', 'getTeamMembers', 'getTeamLeaders']) {
assert.strictEqual(typeof provider[method], 'function', `${method} is missing`)
}
})
test('registration does not call the provider, or touch the database', async () => {
// register() runs while core's app.js is still being required, with the pool
// pointed at a dead port — routeManifest.js and swagger.js both depend on that.
// Registration is a CLAIM; core does not ask anything until it reconciles,
// which is after onBoot.
const ctx = fakeCtx()
let queried = false
const frozen = Object.freeze({ ...ctx, db: Object.freeze({ query: async () => { queried = true; return [] } }) })
const api = fakeApi()
register(frozen, api)
assert.equal(queried, false, 'a query at registration time would hang the manifest and the spec build')
})
test('takes a frozen ctx and does not try to write to it', () => {
const ctx = fakeCtx()
assert.ok(Object.isFrozen(ctx))

View File

@@ -0,0 +1,75 @@
// Protocol 4 membership routing: guild.roster (board state, possibly chunked) and
// guild.leave (a real-time departure, logged like its guild.join counterpart).
const { test, beforeEach } = require('node:test')
const assert = require('node:assert/strict')
const shardIngest = require('../utils/shardIngest')
function makeDeps() {
const calls = { roster: [], memberRemove: [], appended: [], broadcast: [] }
const noop = async () => {}
return {
calls,
shardEvents: { append: async (row) => { calls.appended.push(row); return true } },
shardState: {
upsertGuildRoster: async (ev) => { calls.roster.push(ev) },
removeGuildMember: async (ev) => { calls.memberRemove.push(ev) },
upsertGuild: noop, removeGuild: noop,
clearOnline: noop, upsertOnline: noop, setOffline: noop,
addEconomySample: noop,
},
shardLinks: { removeByAccount: noop },
uoLinkConfig: { recordStatus: noop },
broadcast: (ev) => { calls.broadcast.push(ev) },
pushDispatch: async () => {},
log: { warn() {}, info() {}, error() {} },
}
}
beforeEach(() => shardIngest.reset())
test('guild.roster routes to upsertGuildRoster and is NOT logged', async () => {
// It is board state like guild.update, and the one fat frame on the wire —
// logging it would put a full membership snapshot in shard_events on every
// membership change.
const deps = makeDeps()
const r = await shardIngest.ingest(
{ kind: 'guild.roster', id: 7, seq: 0, more: false, total: 2,
members: [{ serial: '0x1', name: 'Ada' }, { serial: '0x2', name: 'Bo' }], t: 1 },
deps,
)
assert.equal(deps.calls.roster.length, 1)
assert.equal(deps.calls.roster[0].id, 7)
assert.equal(deps.calls.roster[0].members.length, 2)
assert.equal(r.logged, false)
})
test('every frame of a chunked roster reaches the model, seq intact', async () => {
// The sidecar reassembles for its own board, but the live feed and the /history
// backfill both carry individual frames — so the model must see each one with its
// seq, which is what tells it whether to clear the guild first.
const deps = makeDeps()
for (const [seq, more, serial] of [[0, true, '0x1'], [1, true, '0x2'], [2, false, '0x3']]) {
await shardIngest.ingest(
{ kind: 'guild.roster', id: 7, seq, more, total: 3, members: [{ serial, name: serial }], t: 1 },
deps,
)
}
assert.deepEqual(deps.calls.roster.map((e) => e.seq), [0, 1, 2])
assert.deepEqual(deps.calls.roster.map((e) => e.more), [true, true, false])
})
test('guild.leave is logged and broadcast, like guild.join', async () => {
const deps = makeDeps()
const r = await shardIngest.ingest(
{ kind: 'guild.leave', id: 7, name: 'The Cartographers', who: '0x2', t: 2 }, deps)
assert.equal(r.logged, true)
assert.equal(deps.calls.appended.length, 1)
assert.equal(deps.calls.appended[0].kind, 'guild.leave')
assert.equal(deps.calls.broadcast.length, 1)
assert.deepEqual(deps.calls.memberRemove.map((e) => e.who), ['0x2'])
})

View File

@@ -116,6 +116,52 @@ test('acct and webId are stripped below admin regardless of feature config', ()
assert.equal(asAdmin.leader.webId, '42')
})
test('acct and webId are stripped from every member of a guild roster (Protocol 4)', () => {
// A roster is the first frame where the locked fields appear inside an ARRAY of
// actors rather than one nested actor. The walker recurses into arrays, so this
// should already hold — this test is here because it is the difference between a
// public Guilds page listing character names and one publishing 150 account names.
const config = visibility.compileDefaults()
const frame = {
kind: 'guild.roster',
id: 7,
total: 3,
seq: 0,
more: false,
members: [
{ serial: '0x1', name: 'Ada', acct: 'ada_acct', webId: '11', player: true },
{ serial: '0x2', name: 'Bo', acct: 'bo_acct', player: true },
{ serial: '0x3', name: 'Cy', player: true }, // a mobile with no account at all
],
}
for (const level of ['anonymous', 'logged_in', 'player', 'staff']) {
const out = visibility.projectFeature('guilds', frame, level, config)
assert.equal(out.members.length, 3, `${level} still sees every member`)
assert.deepEqual(out.members.map((m) => m.name), ['Ada', 'Bo', 'Cy'])
for (const m of out.members) {
assert.equal('acct' in m, false, `${level} must not see a member's acct`)
assert.equal('webId' in m, false, `${level} must not see a member's webId`)
}
}
const asAdmin = visibility.projectFeature('guilds', frame, 'admin', config)
assert.equal(asAdmin.members[0].acct, 'ada_acct')
assert.equal(asAdmin.members[0].webId, '11')
})
test('guild.roster and guild.leave are mapped, so neither falls closed to admin-only', () => {
// Rule 2 fails an unmapped kind closed. That is the right default, but for these
// two it would silently keep the public Guilds page from ever seeing a roster.
const config = visibility.compileDefaults()
for (const kind of ['guild.roster', 'guild.leave']) {
assert.equal(
visibility.kindVisibleTo(kind, 'anonymous', config), true,
`${kind} should reach an anonymous viewer under the default guilds config`,
)
}
})
test('a stored rule trying to loosen a locked field is ignored', async () => {
withRows([
{ feature: 'guilds', enabled: true, audience: 'anonymous', stream: true, fieldRules: { acct: 'anonymous', webId: 'anonymous' } },
@@ -308,10 +354,16 @@ const PRE_V3_PUBLIC_KINDS = [
// pointedly not among them (its feature ships with stream off).
const V3_ADDED_PUBLIC_KINDS = ['world.ruleset', 'points.board']
test('derived PUBLIC_KINDS is exactly the pre-v3 allowlist plus the v3 additions', () => {
// v4 adds guild membership. Both ride the existing `guilds` feature, which is
// already anonymous, so they join the public set — carrying character names and
// serials, never acct/webId, which the locked-field rules strip by suffix even
// inside the roster's member array (see the roster test above).
const V4_ADDED_PUBLIC_KINDS = ['guild.roster', 'guild.leave']
test('derived PUBLIC_KINDS is exactly the pre-v3 allowlist plus the v3 and v4 additions', () => {
assert.deepEqual(
[...visibility.PUBLIC_KINDS].sort(),
[...PRE_V3_PUBLIC_KINDS, ...V3_ADDED_PUBLIC_KINDS].sort(),
[...PRE_V3_PUBLIC_KINDS, ...V3_ADDED_PUBLIC_KINDS, ...V4_ADDED_PUBLIC_KINDS].sort(),
)
})

View File

@@ -0,0 +1,406 @@
// module-uo's Team provider (docs/website/TEAMS.md §2.3, MODULE_API.md 1.6.0).
//
// The tests that matter here are the REFUSALS. Core's contract is that module
// unavailability becomes staleness and never emptiness, and this module is the
// only thing that can honour it — an empty array from here is read as an
// authoritative "there are none", and core makes destructive decisions from an
// authoritative answer. Every state where this module cannot honestly claim to
// know is asserted below, because each one is a plausible place for someone to
// later "simplify" the guard away and get a plausible-looking empty list.
process.env.DB_HOST = '127.0.0.1'
process.env.DB_PORT = '59999'
const { test, beforeEach, afterEach } = require('node:test')
const assert = require('node:assert/strict')
const core = require('../core')
// The provider reaches the database through core, which is initialised with a ctx
// in production. A minimal one is enough here — the db layer is stubbed anyway.
core.init({
db: { query: async () => [] },
log: () => ({ error() {}, warn() {}, info() {}, debug() {} }),
moduleId: 'uo',
})
const db = require('../model/teamProvider/teamProvider.db')
const uoLinkConfig = require('../model/uoLinkConfig/uoLinkConfig.model')
const uoLinkSocket = require('../utils/uoLinkSocket')
const clilocs = require('../model/shardClilocs/shardClilocs.model')
const visibility = require('../utils/shardVisibility')
const provider = require('../model/teamProvider/teamProvider.model')
const saved = []
function patch(mod, name, fn) {
saved.push([mod, name, mod[name]])
mod[name] = fn
}
// The healthy default: configured, enabled, connected. Each test then breaks only
// the thing it is about.
function healthy() {
patch(uoLinkConfig, 'getSafe', async () => ({ baseUrl: 'http://127.0.0.1:7787', enabled: true }))
patch(uoLinkSocket, 'getState', () => ({ connected: true, running: true }))
// An operator who has never run the client extraction — the default. The standard
// rank names must still resolve from the fallback table.
patch(clilocs, 'resolveMany', async () => new Map())
patch(db, 'listGuildLeaders', async () => [])
}
const guild = (extra = {}) => ({
id: 1, name: 'The Silver Hand', abbr: 'TSH', alliance: null,
members: 2, online: 1, leader_serial: '0x1', leader_name: 'Aldric', leader_acct: 'aldric', ...extra,
})
const member = (extra = {}) => ({
serial: '0x1', name: 'Aldric', acct: 'aldric', web_id: null, is_player: 1,
rank: 1, rank_cliloc: 1062962, rank_name: null,
linked_user_id: null, is_online: 0, ...extra,
})
beforeEach(healthy)
afterEach(() => {
while (saved.length) {
const [mod, name, fn] = saved.pop()
mod[name] = fn
}
})
// ── The refusals ───────────────────────────────────────────────────────────
test('no uo-link configured refuses, on all three methods', async () => {
patch(uoLinkConfig, 'getSafe', async () => ({ baseUrl: null, enabled: false }))
patch(db, 'listGuilds', async () => { throw new Error('must not be read') })
for (const answer of [await provider.getTeams(), await provider.getTeamMembers('1'), await provider.getTeamLeaders('1')]) {
assert.equal(answer.ok, false)
assert.match(answer.reason, /no uo-link configured/)
assert.equal(answer.teams, undefined)
assert.equal(answer.members, undefined)
}
})
test('a disabled integration refuses rather than reporting a frozen board', async () => {
patch(uoLinkConfig, 'getSafe', async () => ({ baseUrl: 'http://x', enabled: false }))
const answer = await provider.getTeams()
assert.equal(answer.ok, false)
assert.match(answer.reason, /disabled/)
})
test('a disconnected socket refuses, even though the board is still there', async () => {
// The tempting mistake, stated as a test: the board is durable and survives an
// outage, so serving it looks harmless. Core cannot tell a board five minutes
// stale from one five days stale, and it archives Teams and departs members
// from a complete answer.
patch(uoLinkSocket, 'getState', () => ({ connected: false, running: true }))
patch(db, 'listGuilds', async () => [guild()])
const answer = await provider.getTeams()
assert.equal(answer.ok, false)
assert.match(answer.reason, /not connected/)
assert.equal(answer.teams, undefined, 'a stale board must not arrive as authoritative')
})
test('a database error refuses instead of throwing at core', async () => {
patch(db, 'listGuilds', async () => { throw new Error('table gone') })
const answer = await provider.getTeams()
assert.equal(answer.ok, false)
assert.match(answer.reason, /table gone/)
})
test('a guild absent from the board refuses rather than reporting an empty roster', async () => {
patch(db, 'findGuild', async () => [])
const members = await provider.getTeamMembers('99')
assert.equal(members.ok, false)
assert.match(members.reason, /not on the board/)
const leaders = await provider.getTeamLeaders('99')
assert.equal(leaders.ok, false)
})
test('a roster that has not arrived yet refuses — the board count is what tells us', async () => {
// Protocol 4's roster arrives on its own frames, separately from the
// guild.update that creates the board row, so there is a real window where a
// 155-member guild has no roster rows. Reporting that as an empty roster would
// depart every member.
patch(db, 'findGuild', async () => [guild({ members: 155 })])
patch(db, 'listGuildMembers', async () => [])
const answer = await provider.getTeamMembers('1')
assert.equal(answer.ok, false)
assert.match(answer.reason, /has not arrived yet/)
assert.match(answer.reason, /155/, 'the count is in the message, because it is the evidence')
})
test('a guild the board says is genuinely empty reports an empty roster', async () => {
// The other side of the same coin: when the board itself says zero, an empty
// roster is the truth and withholding it would freeze a disbanding guild's
// membership forever.
patch(db, 'findGuild', async () => [guild({ members: 0 })])
patch(db, 'listGuildMembers', async () => [])
const answer = await provider.getTeamMembers('1')
assert.equal(answer.ok, true)
assert.deepEqual(answer.members, [])
})
// ── The good answers ───────────────────────────────────────────────────────
test('a guild becomes a Team keyed on its persistent ServUO id', async () => {
// The id survives a rename, which is what lets core apply its rename rule
// instead of seeing an unrelated new guild.
patch(db, 'listGuilds', async () => [guild()])
const answer = await provider.getTeams()
assert.equal(answer.ok, true)
assert.equal(answer.complete, true)
assert.deepEqual(answer.teams, [
{ externalId: '1', name: 'The Silver Hand', abbr: 'TSH', meta: null },
])
})
test('an alliance rides along as opaque meta', async () => {
patch(db, 'listGuilds', async () => [guild({ alliance: 'The Concord' })])
const { teams } = await provider.getTeams()
assert.deepEqual(teams[0].meta, { alliance: 'The Concord' })
})
test('the external id is a string, so core never compares a number to one', async () => {
patch(db, 'listGuilds', async () => [guild({ id: 42 })])
const { teams } = await provider.getTeams()
assert.equal(teams[0].externalId, '42')
})
test('a roster maps to the member shape core expects', async () => {
patch(db, 'findGuild', async () => [guild()])
patch(db, 'listGuildMembers', async () => [
member({ serial: '0x1', name: 'Aldric', rank: 4, rank_cliloc: 1062959, is_online: 1 }),
member({ serial: '0x2', name: 'Bree', acct: null, rank: 1, is_online: 0 }),
])
const { members } = await provider.getTeamMembers('1')
assert.equal(members.length, 2)
assert.equal(members[0].memberKey, '0x1')
assert.equal(members[0].displayName, 'Aldric')
assert.equal(members[0].online, true)
assert.equal(members[0].leader, true, 'rank 4 is Leader')
assert.equal(members[1].leader, false)
assert.equal(members[1].online, false)
})
// ── Rank (the Protocol 4 amendment) ────────────────────────────────────────
test('several members can be leaders at once', async () => {
// The whole reason the wire grew a per-member rank: the board carries one
// leader_serial, so before this only a single leader could ever be reported.
patch(db, 'findGuild', async () => [guild()])
patch(db, 'listGuildMembers', async () => [
member({ serial: '0x1', rank: 4 }),
member({ serial: '0x2', rank: 4 }),
member({ serial: '0x3', rank: 3 }),
])
const { members } = await provider.getTeamMembers('1')
assert.deepEqual(members.filter((m) => m.leader).map((m) => m.memberKey), ['0x1', '0x2'])
})
test('getTeamLeaders returns everyone at rank 4, not just the board’s one', async () => {
patch(db, 'findGuild', async () => [guild({ leader_serial: '0x1' })])
patch(db, 'listGuildLeaders', async () => [{ serial: '0x1' }, { serial: '0x2' }])
assert.deepEqual((await provider.getTeamLeaders('1')).leaders, ['0x1', '0x2'])
})
test('the board’s leader is kept even when no roster row has rank yet', async () => {
// A shard whose roster has not been re-emitted since the amendment has no ranks
// stored. The founder-leader comes from a different frame and must not be lost
// by moving to ranks.
patch(db, 'findGuild', async () => [guild({ leader_serial: '0x9' })])
patch(db, 'listGuildLeaders', async () => [])
assert.deepEqual((await provider.getTeamLeaders('1')).leaders, ['0x9'])
})
test('the board’s leader is not duplicated when they also hold rank 4', async () => {
patch(db, 'findGuild', async () => [guild({ leader_serial: '0x1' })])
patch(db, 'listGuildLeaders', async () => [{ serial: '0x1' }, { serial: '0x2' }])
const { leaders } = await provider.getTeamLeaders('1')
assert.equal(new Set(leaders).size, leaders.length)
})
test('a NULL rank is not a leader — "not known" is not "leads this guild"', async () => {
// The shard withholds the rank for a staff account, because ServUO's GuildRank
// getter reports Leader for anyone at GameMaster or above whatever their real
// rank. Reading the absence as leadership would republish exactly that lie.
patch(db, 'findGuild', async () => [guild()])
patch(db, 'listGuildMembers', async () => [member({ serial: '0x1', rank: null, rank_cliloc: null })])
const { members } = await provider.getTeamMembers('1')
assert.equal(members[0].leader, false)
assert.equal(members[0].rankLabel, null)
})
test('a standard rank resolves to its name without a cliloc table', async () => {
// The operator may never have run the client extraction, and a roster should
// still read "Warlord" rather than nothing.
patch(db, 'findGuild', async () => [guild()])
patch(db, 'listGuildMembers', async () => [
member({ serial: '0x1', rank: 4, rank_cliloc: 1062959 }),
member({ serial: '0x2', rank: 3, rank_cliloc: 1062960 }),
member({ serial: '0x3', rank: 0, rank_cliloc: 1062963 }),
])
const { members } = await provider.getTeamMembers('1')
assert.deepEqual(members.map((m) => m.rankLabel), ['Leader', 'Warlord', 'Ronin'])
})
test('the operator’s cliloc table wins over the built-in names', async () => {
// A localised or edited client should name the ranks, not this module's English
// fallback.
patch(clilocs, 'resolveMany', async () => new Map([[1062960, 'Kriegsherr']]))
patch(db, 'findGuild', async () => [guild()])
patch(db, 'listGuildMembers', async () => [member({ serial: '0x1', rank: 3, rank_cliloc: 1062960 })])
assert.equal((await provider.getTeamMembers('1')).members[0].rankLabel, 'Kriegsherr')
})
test('a custom rank’s literal name beats both', async () => {
// A shard that replaced RankDefinition.Ranks sends a string instead of a cliloc,
// and its own naming has to survive.
patch(clilocs, 'resolveMany', async () => new Map([[1062960, 'Warlord']]))
patch(db, 'findGuild', async () => [guild()])
patch(db, 'listGuildMembers', async () => [
member({ serial: '0x1', rank: 3, rank_cliloc: 1062960, rank_name: 'Sword-Captain' }),
])
assert.equal((await provider.getTeamMembers('1')).members[0].rankLabel, 'Sword-Captain')
})
test('a failing cliloc lookup falls back rather than failing the roster', async () => {
patch(clilocs, 'resolveMany', async () => { throw new Error('cliloc table missing') })
patch(db, 'findGuild', async () => [guild()])
patch(db, 'listGuildMembers', async () => [member({ serial: '0x1', rank: 3, rank_cliloc: 1062960 })])
const answer = await provider.getTeamMembers('1')
assert.equal(answer.ok, true, 'a label is decoration; losing it must not lose the roster')
assert.equal(answer.members[0].rankLabel, 'Warlord')
})
test('an unknown cliloc leaves the label null rather than inventing one', async () => {
patch(db, 'findGuild', async () => [guild()])
patch(db, 'listGuildMembers', async () => [member({ serial: '0x1', rank: 2, rank_cliloc: 9999999 })])
assert.equal((await provider.getTeamMembers('1')).members[0].rankLabel, null)
})
test('a member with no account at all is fine and unlinked', async () => {
// §2.3 of the protocol spec: acct is genuinely optional — a PlayerMobile can
// have no Account, and the local test world contains such mobiles.
patch(db, 'findGuild', async () => [guild()])
patch(db, 'listGuildMembers', async () => [member({ acct: null, web_id: null, linked_user_id: null })])
const { members } = await provider.getTeamMembers('1')
assert.equal(members[0].userId, null)
})
test('userId comes from the roster’s web_id first, then the link table', async () => {
patch(db, 'findGuild', async () => [guild()])
patch(db, 'listGuildMembers', async () => [
member({ serial: '0xA', web_id: '7', linked_user_id: 99 }), // roster wins
member({ serial: '0xB', web_id: null, linked_user_id: 12 }), // fallback
member({ serial: '0xC', web_id: '0', linked_user_id: null }), // neither
])
const { members } = await provider.getTeamMembers('1')
assert.equal(members[0].userId, 7, 'what the shard itself asserted at roster time')
assert.equal(members[1].userId, 12, 'the fallback for a row that predates the link')
assert.equal(members[2].userId, null)
})
test('web_id arrives as a string from the wire and is coerced', async () => {
patch(db, 'findGuild', async () => [guild()])
patch(db, 'listGuildMembers', async () => [member({ web_id: '42' })])
const { members } = await provider.getTeamMembers('1')
assert.equal(members[0].userId, 42)
assert.equal(typeof members[0].userId, 'number')
})
test('a guild with no leader anywhere reports none rather than guessing', async () => {
patch(db, 'findGuild', async () => [guild({ leader_serial: null })])
patch(db, 'listGuildLeaders', async () => [])
const answer = await provider.getTeamLeaders('1')
assert.equal(answer.ok, true)
assert.deepEqual(answer.leaders, [])
})
test('an empty board is an authoritative empty list — the shard really has no guilds', async () => {
// Distinct from every refusal above: the socket is connected and the board is
// readable, so "no guilds" is a fact. Core still quarantines it before acting.
patch(db, 'listGuilds', async () => [])
const answer = await provider.getTeams()
assert.equal(answer.ok, true)
assert.deepEqual(answer.teams, [])
})
// ── projectRoster (TEAMS.md §3.3) ──────────────────────────────────────────
//
// The refusal semantics INVERT here and that is the point of these tests. For
// the three methods above, a refusal means "change nothing" and an empty array
// would be destructive. For this one, core fails CLOSED — a refusal withholds the
// roster — so the dangerous answer is the opposite: returning every key because
// the config could not be read would publish a roster an operator gated to staff.
const rows = [{ member_key: '0x1' }, { member_key: '0x2' }]
function guilds(feature) {
patch(visibility, 'getConfig', async () => ({ guilds: feature }))
}
test('a viewer at or above the audience sees every row', async () => {
guilds({ enabled: true, audience: 'anonymous' })
const answer = await provider.projectRoster('1', rows, null)
assert.equal(answer.ok, true)
assert.deepEqual(answer.members, ['0x1', '0x2'])
})
test('a viewer below the audience sees none — authoritatively, not as a refusal', async () => {
// `ok: true` with an empty list is the correct answer here: this module KNOWS
// the viewer may see nothing. Core renders an empty roster rather than an
// error, which is what a gated shard is supposed to look like.
guilds({ enabled: true, audience: 'staff' })
const answer = await provider.projectRoster('1', rows, { userId: 7, role: 'player' })
assert.equal(answer.ok, true)
assert.deepEqual(answer.members, [])
})
test('an admin clears every audience', async () => {
guilds({ enabled: true, audience: 'admin' })
const answer = await provider.projectRoster('1', rows, { userId: 1, role: 'admin' })
assert.deepEqual(answer.members, ['0x1', '0x2'])
})
test('a disabled guilds feature hides the roster from everyone, staff included', async () => {
// The switch means "this shard does not publish guild data", not "publish it
// quietly to staff".
guilds({ enabled: false, audience: 'anonymous' })
const answer = await provider.projectRoster('1', rows, { userId: 1, role: 'admin' })
assert.equal(answer.ok, true)
assert.deepEqual(answer.members, [])
})
test('an unreadable visibility config REFUSES rather than publishing', async () => {
// The inversion, stated. Core reads this as "withhold", which is the only safe
// reading of "I could not work out who is allowed to look".
patch(visibility, 'getConfig', async () => { throw new Error('pool down') })
const answer = await provider.projectRoster('1', rows, null)
assert.equal(answer.ok, false)
assert.match(answer.reason, /visibility could not be resolved/)
})
test('an absent viewer is anonymous, not an error', async () => {
guilds({ enabled: true, audience: 'logged_in' })
const answer = await provider.projectRoster('1', rows, null)
assert.equal(answer.ok, true)
assert.deepEqual(answer.members, [], 'anonymous does not meet logged_in')
})
test('rows with no member key are dropped rather than answered as blanks', async () => {
guilds({ enabled: true, audience: 'anonymous' })
const answer = await provider.projectRoster('1', [{ member_key: '0x1' }, { member_key: null }], null)
assert.deepEqual(answer.members, ['0x1'])
})

View File

@@ -45,6 +45,12 @@ const LOGGED_KINDS = new Set([
'server.crashed',
// Protocol 2.0: a real-time guild join (the board itself is state, not logged).
'guild.join',
// Protocol 4: the departure counterpart to guild.join, and logged for the same
// reason — it is what a "so-and-so left" feed reads. `guild.roster` deliberately
// stays out: it is board state like guild.update, and it is the one fat frame on
// the wire (~69 bytes per member), so logging it would bloat shard_events with
// a full membership snapshot on every membership change.
'guild.leave',
// Protocol 2.0 provisioning audit (admin channel only — not in PUBLIC_KINDS).
'account.audit',
'account.unlinked',
@@ -187,6 +193,16 @@ async function applyStateChange(event, deps) {
case 'guild.remove':
await shardState.removeGuild(event.id)
return
// Protocol 4: membership. A roster arrives in one frame for any realistic
// guild and in several for one over the shard's cap — upsertGuildRoster
// handles both. guild.leave is advisory; the next roster would converge
// anyway, but applying it shows the departure at once.
case 'guild.roster':
await shardState.upsertGuildRoster(event)
return
case 'guild.leave':
await shardState.removeGuildMember(event)
return
case 'city.update':
// Upserts the board AND captures term history (idempotent).
await shardState.upsertGovernor(event)

View File

@@ -163,6 +163,13 @@ const KIND_FEATURE = new Map(
'guild.update': 'guilds',
'guild.remove': 'guilds',
'guild.join': 'guilds',
// Protocol 4. Both carry actor data — a roster is an array of actor objects
// and guild.leave names a serial — so they ride the same `guilds` feature and
// the same locked-field rules: `acct`/`webId` inside a roster member are
// stripped below admin by suffix, exactly as `guild.leader.acct` already is.
// Without these two lines rule 2 would fail them closed to admin-only.
'guild.roster': 'guilds',
'guild.leave': 'guilds',
'city.update': 'governors',
'presence.online': 'presence',
'region.enter': 'presence',

54
sonar-project.properties Normal file
View File

@@ -0,0 +1,54 @@
# SonarQube analysis config for module-uo.
# Consumed by the scanner in .gitea/workflows/sonarqube.yml on push to main.
# The project key must match the one created in SonarQube (dashboard URL
# ?id=Module-uo).
sonar.projectKey=Module-uo
sonar.projectName=Module-uo
# Analysed application code.
#
# Unlike core's repo there is no `src/` directory to point at: the server half
# keeps its code at `server/` root (boot.js, core.js, index.js) beside its
# subdirectories, so the whole tree is included and the non-source parts are
# excluded below. That direction is deliberate — a new top-level server
# directory is scanned by default rather than silently unscanned, which is the
# safer way for this list to be wrong.
#
# `client/scripts` and `server/scripts` are in, not out: checkExternals.js and
# checkImports.js *are* the enforcement of MODULE_API.md §3.6 and §5.1, they
# each carry their own test suite, and both have already shipped defects that a
# reviewer missed (see MODULE_SYSTEM.md §2.7.1). Build code that decides whether
# a release is allowed out is not throwaway code.
sonar.sources=server,client/src,client/scripts
# Test code is analysed separately from sources so coverage/metrics attribute
# correctly. Both halves run on Node's built-in test runner (no browser/DOM):
# the server suite is CommonJS behind test/_setup.js, the client's is ESM.
sonar.tests=server/test,client/test
sonar.test.inclusions=server/test/**/*.test.js,client/test/**/*.test.js
# Coverage. The sonarqube.yml workflow runs both suites with Node's built-in
# test-coverage and writes an LCOV report for each BEFORE the scan runs; without
# them the dashboard shows 0% (the scanner never executes tests itself). Both
# suites are invoked from the repo root so the `SF:` paths come out
# repo-root-relative (server/..., client/src/...) and the scanner resolves them
# against the project base dir.
sonar.javascript.lcov.reportPaths=server/coverage/lcov.info,client/coverage/lcov.info
# Test execution ("Unit Tests" measure). A SEPARATE report from coverage: the
# lcov files above only populate Coverage, so without this the dashboard shows a
# coverage % but an empty "Unit Tests" tile. Written by scripts/sonar-test-reporter.mjs,
# a copy of core's — a pure leaf build helper, which is the side of the vendoring
# line that may be copied (MODULE_SYSTEM.md §2.7.1).
sonar.testExecutionReportPaths=server/coverage/test-execution.xml,client/coverage/test-execution.xml
# Never analyse dependencies, build output, generated artifacts, or fixtures.
#
# `client/dist` is the built chunk (gitignored, but the workflow builds it before
# scanning because client/test/{build,registration}.test.js import it).
# `server/swagger/doc.js` and the two committed generated artifacts at the repo
# root are inputs to and outputs of swagger-autogen, not hand-written code.
sonar.exclusions=**/node_modules/**,server/test/**,client/test/**,client/dist/**,server/swagger/**,server/data/**,server/coverage/**,client/coverage/**,**/*.min.js
sonar.sourceEncoding=UTF-8

View File

@@ -3131,6 +3131,56 @@
}
}
},
"/api/v1/public/shard/guilds/{id}": {
"get": {
"tags": [
"Public · Shard"
],
"summary": "One guild and its roster",
"description": "The detail view behind the board. Gated and projected through the same `guilds` feature, so an operator who raises that audience raises this too, and the locked acct/webId fields never survive below admin — a roster is where they appear in bulk. This page is also where core renders the Team activity feed, through the `uo.guild.detail` extension slot.",
"parameters": [
{
"name": "id",
"in": "path",
"required": true,
"schema": {
"type": "string"
},
"description": "The guild id."
}
],
"responses": {
"200": {
"description": "The guild, with its roster",
"content": {
"application/json": {
"schema": {
"type": "object",
"additionalProperties": true
}
}
}
},
"403": {
"description": "Forbidden"
},
"404": {
"description": "No such guild",
"content": {
"application/json": {
"schema": {
"type": "object",
"additionalProperties": true
}
}
}
},
"500": {
"description": "Internal Server Error"
}
}
}
},
"/api/v1/public/shard/houses": {
"get": {
"tags": [