feat(shard): ingest guild rosters and departures (protocol 4)
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>
This commit is contained in:
75
server/test/shardIngest.guildRoster.test.js
Normal file
75
server/test/shardIngest.guildRoster.test.js
Normal 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'])
|
||||
})
|
||||
@@ -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(),
|
||||
)
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user