diff --git a/server/index.js b/server/index.js index f62a4b7..96c91fc 100644 --- a/server/index.js +++ b/server/index.js @@ -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) diff --git a/server/model/teamProvider/teamProvider.db.js b/server/model/teamProvider/teamProvider.db.js new file mode 100644 index 0000000..49024cc --- /dev/null +++ b/server/model/teamProvider/teamProvider.db.js @@ -0,0 +1,66 @@ +// 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. + +const core = require('../../core') + +const query = (...args) => core.db.query(...args) + +/** + * 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, + 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], + ) + +module.exports = { listGuilds, findGuild, listGuildMembers } diff --git a/server/model/teamProvider/teamProvider.model.js b/server/model/teamProvider/teamProvider.model.js new file mode 100644 index 0000000..a18888f --- /dev/null +++ b/server/model/teamProvider/teamProvider.model.js @@ -0,0 +1,182 @@ +// ── 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 log = core.logger('teams') + +/** 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 leaderSerial = guild.leader_serial || null + return { + ok: true, + complete: true, + members: rows.map((row) => ({ + memberKey: row.serial, + displayName: row.name || null, + // Not on the wire. The roster member is the standard actor object, which + // carries no guild rank — see the note at the bottom of this file. + rankLabel: null, + leader: Boolean(leaderSerial && row.serial === leaderSerial), + 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)` — who leads the guild. + * + * **One leader, because that is all the wire carries.** TEAMS.md §2.5 expects + * multiple leaders to be the normal case, from `PlayerMobile.GuildRank.Rank >= 4`, + * and core supports them — but Protocol 4's roster member is the standard actor + * object with no rank field, so the only leadership this module can see is the + * board's single `leader_serial` from `guild.update`. Reporting a guessed second + * leader would be worse than reporting one honestly. + * + * Raising this to the full set is a protocol change (rank on the actor object), + * not something this file can fix. + */ +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`) + return { ok: true, leaders: guild.leader_serial ? [guild.leader_serial] : [] } + } catch (err) { + log.warn('getTeamLeaders failed', { externalId, message: err.message }) + return refuse(`leadership unreadable: ${err.message}`) + } +} + +/** + * 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 +} + +module.exports = { getTeams, getTeamMembers, getTeamLeaders, boardIsCurrent } diff --git a/server/test/_fakes.js b/server/test/_fakes.js index 3f13c62..8aaee4d 100644 --- a/server/test/_fakes.js +++ b/server/test/_fakes.js @@ -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 }, } diff --git a/server/test/entry.test.js b/server/test/entry.test.js index cc4ee2d..91b9249 100644 --- a/server/test/entry.test.js +++ b/server/test/entry.test.js @@ -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)) diff --git a/server/test/teamProvider.test.js b/server/test/teamProvider.test.js new file mode 100644 index 0000000..4780b15 --- /dev/null +++ b/server/test/teamProvider.test.js @@ -0,0 +1,246 @@ +// 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 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 })) +} + +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, + 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', is_online: 1 }), + member({ serial: '0x2', name: 'Bree', acct: null, 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, 'matches the board’s leader_serial') + assert.equal(members[1].leader, false) + assert.equal(members[1].online, false) +}) + +test('rankLabel is null, honestly — the wire carries no guild rank', async () => { + // The roster member is the standard actor object (serial, name, player, acct?, + // webId?). Inventing a rank from the leader flag would be core displaying a + // label this module made up. + patch(db, 'findGuild', async () => [guild()]) + patch(db, 'listGuildMembers', async () => [member()]) + const { members } = await provider.getTeamMembers('1') + assert.equal(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('leadership is the board’s single leader — one, and honestly one', async () => { + // TEAMS.md §2.5 expects multiple leaders (GuildRank.Rank >= 4) and core + // supports them, but Protocol 4 does not put rank on the wire. Raising this to + // the full set is a protocol change, not something this file can fix. + patch(db, 'findGuild', async () => [guild({ leader_serial: '0x1' })]) + assert.deepEqual((await provider.getTeamLeaders('1')).leaders, ['0x1']) +}) + +test('a guild with no leader on the board reports none rather than guessing', async () => { + patch(db, 'findGuild', async () => [guild({ leader_serial: null })]) + 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, []) +})