4 Commits

Author SHA1 Message Date
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
13 changed files with 536 additions and 5 deletions

View File

@@ -61,12 +61,19 @@
# #
# Runner: the shared self-hosted `ubuntu-latest` runner. These jobs need only # Runner: the shared self-hosted `ubuntu-latest` runner. These jobs need only
# Node — no Docker socket, no database. # 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 name: PR Checks
on: on:
pull_request: pull_request:
branches: [main] branches: [main, edge]
# A newer push to the same PR cancels the in-flight run. # A newer push to the same PR cancels the in-flight run.
concurrency: 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` branch and no cutover, unlike `website`, whose module work accumulates on `edge`
and reaches `main` once. 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 ### Commit messages
We use [Conventional Commits](https://www.conventionalcommits.org/) — We use [Conventional Commits](https://www.conventionalcommits.org/) —

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_presence`;
DROP TABLE IF EXISTS `shard_governor_terms`; DROP TABLE IF EXISTS `shard_governor_terms`;
DROP TABLE IF EXISTS `shard_governors`; DROP TABLE IF EXISTS `shard_governors`;
DROP TABLE IF EXISTS `shard_guild_members`;
DROP TABLE IF EXISTS `shard_guilds`; DROP TABLE IF EXISTS `shard_guilds`;
DROP TABLE IF EXISTS `shard_pages`; DROP TABLE IF EXISTS `shard_pages`;
DROP TABLE IF EXISTS `shard_champs`; DROP TABLE IF EXISTS `shard_champs`;

View File

@@ -220,6 +220,34 @@ CREATE TABLE IF NOT EXISTS shard_guilds (
INDEX idx_shard_guilds_name (name) INDEX idx_shard_guilds_name (name)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; ) 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,
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)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- Town-governor board (Protocol 2.0, City Loyalty). One row per city, upserted on -- 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 -- 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 -- the set of cities is fixed). governor / governorElect are actor objects

View File

@@ -171,6 +171,37 @@ const removeGuild = (id) => query('DELETE FROM shard_guilds WHERE id = ?', [id])
const clearGuilds = () => query('DELETE FROM shard_guilds') const clearGuilds = () => query('DELETE FROM shard_guilds')
const listGuilds = () => query(`SELECT ${GUILD_COLS} FROM shard_guilds ORDER BY name ASC`) const listGuilds = () => query(`SELECT ${GUILD_COLS} FROM shard_guilds ORDER BY name ASC`)
// ── Guild membership (Protocol 4) ──────────────────────────────────────────
const MEMBER_COLS = 'guild_id, serial, name, acct, web_id, is_player, 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.
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.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), 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 // 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 // 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. // is not modelled (the board carries only counts + leader), so we don't guess it.
@@ -342,6 +373,11 @@ module.exports = {
removeGuild, removeGuild,
clearGuilds, clearGuilds,
listGuilds, listGuilds,
upsertGuildMembers,
clearGuildMembers,
removeGuildMember,
clearAllGuildMembers,
listGuildMembers,
findGuildLedByActor, findGuildLedByActor,
listGuildsLedByAccounts, listGuildsLedByAccounts,
upsertGovernor, upsertGovernor,

View File

@@ -340,8 +340,80 @@ async function upsertGuild(ev) {
}) })
} }
const removeGuild = (id) => (id == null ? Promise.resolve() : db.removeGuild(id)) const removeGuild = async (id) => {
const clearGuilds = () => db.clearGuilds() 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,
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) { function shapeGuild(r) {
const payload = typeof r.payload === 'string' ? safeJson(r.payload) : r.payload const payload = typeof r.payload === 'string' ? safeJson(r.payload) : r.payload
@@ -620,6 +692,9 @@ module.exports = {
removeGuild, removeGuild,
clearGuilds, clearGuilds,
listGuilds, listGuilds,
upsertGuildRoster,
removeGuildMember,
listGuildMembers,
replaceGuilds, replaceGuilds,
findGuildForActor, findGuildForActor,
listGuildsLedForAccounts, listGuildsLedForAccounts,

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') 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 () => { test('a stored rule trying to loosen a locked field is ignored', async () => {
withRows([ withRows([
{ feature: 'guilds', enabled: true, audience: 'anonymous', stream: true, fieldRules: { acct: 'anonymous', webId: 'anonymous' } }, { 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). // pointedly not among them (its feature ships with stream off).
const V3_ADDED_PUBLIC_KINDS = ['world.ruleset', 'points.board'] 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( assert.deepEqual(
[...visibility.PUBLIC_KINDS].sort(), [...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

@@ -45,6 +45,12 @@ const LOGGED_KINDS = new Set([
'server.crashed', 'server.crashed',
// Protocol 2.0: a real-time guild join (the board itself is state, not logged). // Protocol 2.0: a real-time guild join (the board itself is state, not logged).
'guild.join', '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). // Protocol 2.0 provisioning audit (admin channel only — not in PUBLIC_KINDS).
'account.audit', 'account.audit',
'account.unlinked', 'account.unlinked',
@@ -187,6 +193,16 @@ async function applyStateChange(event, deps) {
case 'guild.remove': case 'guild.remove':
await shardState.removeGuild(event.id) await shardState.removeGuild(event.id)
return 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': case 'city.update':
// Upserts the board AND captures term history (idempotent). // Upserts the board AND captures term history (idempotent).
await shardState.upsertGovernor(event) await shardState.upsertGovernor(event)

View File

@@ -163,6 +163,13 @@ const KIND_FEATURE = new Map(
'guild.update': 'guilds', 'guild.update': 'guilds',
'guild.remove': 'guilds', 'guild.remove': 'guilds',
'guild.join': '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', 'city.update': 'governors',
'presence.online': 'presence', 'presence.online': 'presence',
'region.enter': '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