// ── The Team provider ───────────────────────────────────────────────────── // // A **Team** is a core platform entity: core owns the tables, the reconciler that // keeps them in step, the access rules, the forum and the activity feed. What // core does not own is the word. This game calls them clans, the next will call // them companies, and a core that picked one would be publishing a noun it // invented. So core asks, and this file is the whole of the answer. // // Registered from `index.js` with `api.registerTeamProvider(...)` (MODULE_API 1.6.0). // // ── Why this registration is unlike every other one ─────────────────────── // // It is the first place **core calls the module and waits**. `registerRoutes` // hands core a router to mount, `registerNav` hands it a row to draw, // `registerPostHook` asks to be told when something happens. This hands over // something core will pick up and call — from its reconciler, and (for // `projectRoster`) on a request path with someone waiting on the other end. // // That inversion is what every rule below follows from: // // • **Core's budget is 10 seconds** and it is core's, not yours. Past it the // call is a refusal, whatever your function eventually returns. // • **Every method returns an ENVELOPE, never a bare array.** A rejected // promise, a synchronous throw, a timeout, a non-object, a missing `ok`, a // malformed row — core reads every one of them as `{ ok: false }`. There is // no shape a failure can take that core reads as "zero teams", which is the // entire argument for the envelope: a bare array has exactly one such shape, // `[]`, and it is the one a module returns while its sidecar is connecting. // • **Refusing is normal.** `{ ok: false }` is an ordinary answer and not an // error you failed to handle. Core keeps the projection it already has, // records your reason and shows it to an operator. Nothing empties. // • **`projectRoster` is the exception, and it fails CLOSED** — see it below. // // ── The one that is easy to get wrong ───────────────────────────────────── // // Answering `{ ok: true, teams: [] }` because the game is unreachable. It reads // as "this deployment has no clans", which is an authoritative statement, and core // acts on authoritative statements: it archives Teams that have stopped existing // and departs members who have left. A cold start would empty every roster on the // site, and the module would have done it by being helpful. // // So the guard is the first line of three of the four methods, and it is // deliberately conservative: an unreachable game refuses, even though the tables // below still hold a perfectly readable snapshot. Core cannot tell a snapshot // five minutes old from one five days old, and it makes destructive decisions // from a complete answer. const core = require('../../core') const db = require('./clanProvider.db') const settings = require('./clanSettings') const worldStatus = require('../worldStatus/worldStatus.model') const log = core.logger('clans') /** A refusal, in the shape core reads. */ const refuse = (reason) => ({ ok: false, reason }) /** * Is what these tables hold current enough to answer with? * * The template has no sidecar, so it asks the freshness the rest of it already * tracks: if nothing has reported in longer than the world-status window, the * clan tables are a snapshot of unknown age. In a real module this is "is my * sidecar socket connected", asked of the socket rather than of a status column — * a process that has just started has not transitioned yet, so a persisted * `connected` can be left over from the last run. */ async function gameIsReachable() { const status = await worldStatus.getPublicStatus() if (status.stale) return { ok: false, reason: 'the game has not reported recently; clan data may be stale' } if (!status.online) return { ok: false, reason: 'the game is offline' } return { ok: true } } /** * `getTeams()` — every clan this deployment has. * * `externalId` is the game's own stable id, and choosing it is the one genuinely * load-bearing decision in this file. **It must survive a rename**: core reads a * known id with a new name as a rename and keeps the Team, its forum and its * history; it reads an unknown id as a new Team and archives the old one. Handing * over the clan's NAME as its id turns every rename into "the clan was deleted * and a different one appeared", taking the forum with it. * * `meta` is an opaque object core stores and displays and never branches on. It * is how a concept core has no word for — an alliance, a faction, a season — * reaches a Team page without core acquiring an opinion about it. */ async function getTeams() { const ready = await gameIsReachable() if (!ready.ok) return refuse(ready.reason) try { const rows = await db.listClans() return { ok: true, // `complete: true` says "this is every clan there is", which is what // licenses core to archive the ones missing from it. A module that can only // answer about some of them — a paged source, a partial cache — must leave // it off, and core then adds and updates without ever archiving. complete: true, teams: rows.map((row) => ({ externalId: String(row.externalId), name: row.name, abbr: row.abbr || null, meta: null, })), } } catch (err) { // The catch is not decoration. An unhandled rejection here would reach core's // reconciler as a rejected promise, which it reads as a refusal anyway — but // then nothing has logged your side of it, and the operator sees a Team sync // that stopped with core blamed for it. log.warn('getTeams failed', { message: err.message }) return refuse(`clan list unreadable: ${err.message}`) } } /** * `getTeamMembers(externalId)` — one clan's roster. * * **An empty roster is refused unless the game says the clan is empty.** The * clan row and its members arrive on separate frames in any real ingest, so there * is a window — a clan created seconds ago, a website that connected between the * two — where core would otherwise be told authoritatively that a 40-member clan * has nobody in it, and would depart all forty. `member_count` is what * distinguishes "empty" from "not here yet", and it is the only thing that can: * this is why the schema keeps a count the rows cannot supply. */ async function getTeamMembers(externalId) { const ready = await gameIsReachable() if (!ready.ok) return refuse(ready.reason) try { const clan = await db.findClan(externalId) if (!clan) return refuse(`clan ${externalId} is unknown`) const rows = await db.listMembers(externalId) if (!rows.length && clan.memberCount > 0) { return refuse(`roster for clan ${externalId} has not arrived yet (the game says ${clan.memberCount})`) } return { ok: true, complete: true, members: rows.map((row) => ({ memberKey: row.memberKey, displayName: row.displayName || null, rankLabel: row.rankLabel || null, leader: Boolean(row.isLeader), online: Boolean(row.isOnline), // Resolved by THIS module, from this module's own link table. Core does // not resolve it and could not: the game↔site mapping is yours, and a // core that read it would be core reading a module's table by name. userId: row.userId || null, })), } } catch (err) { log.warn('getTeamMembers failed', { externalId, message: err.message }) return refuse(`roster unreadable: ${err.message}`) } } /** * `getTeamLeaders(externalId)` — every member who leads, by member key. * * **Plural, and answer it plurally.** Core treats multiple leaders as the normal * case; a provider that can only name one is a provider whose deployment has one, * not a shape core assumes. Leadership is what core grants forum moderation and * Team-management rights from, so a leader missing here is a leader locked out of * their own clan's forum. * * Keys, not rows: core already has the roster and only needs to know which of * those keys lead. A key that is not in the roster is ignored rather than * inventing a member. */ async function getTeamLeaders(externalId) { const ready = await gameIsReachable() if (!ready.ok) return refuse(ready.reason) try { const clan = await db.findClan(externalId) if (!clan) return refuse(`clan ${externalId} is unknown`) const rows = await db.listMembers(externalId) return { ok: true, leaders: rows.filter((r) => r.isLeader).map((r) => r.memberKey) } } catch (err) { log.warn('getTeamLeaders failed', { externalId, message: err.message }) return refuse(`leadership unreadable: ${err.message}`) } } /** * May this viewer see this clan's roster? The audience model itself. * * **One rule, two callers**, and keeping it that way is the point of the split. * `projectRoster` below answers the question for CORE's roster; the module's own * `/public/clans/:externalId` route answers it for its own page. A second copy of * the rule is a copy that drifts, and the drift is silent in the direction that * matters — the page publishing what core is withholding. * * `viewer` is `{ userId, role }` or `null` for an anonymous caller. Core never * hands over the `users` row, which would make every column of that table part of * the contract. * * Throws rather than guessing when it cannot decide; both callers treat a throw * as "withhold". */ async function rosterVisibleTo(externalId, viewer) { const audience = await settings.getRosterAudience() if (audience === 'public') return true // **Anonymous is an ANSWER, not a failed lookup.** Core hands over `null` for a // viewer with no session, and treating that as "I could not work out who this // is" would refuse — serving an empty roster to every visitor on a deployment // whose clans are public. if (!viewer) return false if (audience === 'staff') return viewer.role === 'admin' || viewer.role === 'moderator' // `'members'`: someone whose account is behind a character in this clan. // Resolved from this module's own roster, the only place that mapping exists. const roster = await db.listMembers(externalId) return roster.some((r) => r.userId && r.userId === viewer.userId) } /** * `projectRoster(externalId, members, viewer)` — who may see this roster. * * Optional, and the only method core calls on a REQUEST path. Core holds the * roster and its public shape; the question that is yours is *who is allowed to * look*, because the audience model is yours and core does not have one. * * **This one fails CLOSED, and the asymmetry is the point.** For the other three, * an unanswered call must change nothing — core keeps what it has. For this one, * "keep what you have" means serving the roster unprojected to whoever asked, * which is a leak. So core distinguishes two refusals, and you get the right one * without doing anything: * * • **no provider, or no `projectRoster`** — there is no audience model to * consult and nothing is being withheld, so core serves the roster whole at * its own public shape. That is what makes this member genuinely optional: * omit it and a deployment with no rungs of its own still renders. * • **a `projectRoster` that refused, threw, timed out or answered malformed** * — core serves an EMPTY roster and says so (`projected: false`, * `projectionUnavailable: true`). You said you had an opinion and then did * not give it. * * **Note what it does not gate on: whether the game is reachable.** Visibility is * a question about this deployment's configuration, not about the game — and * refusing here because a socket is down would blank a public roster every time * the game restarted. * * **Withhold rows; do not strip fields.** Core's public roster shape already * omits the member key and the site account id, so there is nothing here to * redact. Return every key or none — and "every key or none" is the honest * translation of an audience model that is a property of the FEATURE rather than * of a member. */ async function projectRoster(externalId, members, viewer) { try { const visible = await rosterVisibleTo(externalId, viewer) return { ok: true, members: visible ? members.map((m) => m.member_key) : [] } } catch (err) { // Refusing is what withholds the roster. The tempting alternative — return // every key, because the lookup failed and the rows are right there — // publishes a roster an operator may have gated to staff. log.warn('projectRoster could not resolve visibility; withholding the roster', { externalId, message: err.message, }) return refuse(`visibility could not be resolved: ${err.message}`) } } // Where core should point a link at a clan. // // **Data, not a method**, and the fifth member of the provider. Core cannot work // this out for itself and is not supposed to: Teams have no core surface, so the // module that owns the vocabulary owns the page, and the one thing core needs // back is where that page lives. A notification email about a forum reply that // cannot take you to the thread is most of the way to useless. // // Core substitutes `{externalId}` and `{slug}` and does nothing else with it. A // **relative path only** — a template naming its own host is refused at // registration, protocol-relative `//host/x` with it, because there is no reason // for a module to redirect the site's outbound mail. // // It must match the route `client/src/entry.jsx` registers, and nothing checks // that for you across the two halves. Omit the member and the deployment loses // clickable links in Team notification email; omit the ROUTE and it gets links to // a page that does not exist, which is worse. const pageUrlTemplate = '/examplegame/clans/{externalId}' // ── The audience resolver ───────────────────────────────────────────────── // // Registered in `index.js` as `examplegame.clan.members` and called by core when // a rule pointed at that audience fires. It lives beside the provider because it // answers a question about the same rows, and it is NOT part of the provider — // core calls it through the audience registry, not through the five members // above. // // **Three rules, and every one of them protects somebody's mailbox rather than // this module's correctness.** // // 1. Return user ids and nothing else. You are not handed a template, a channel // or an address, and you may not enumerate them; core maps ids to addresses // on its own side, after preferences, suppression and the verification gate. // 2. Never widen on failure. A resolver that cannot answer returns the EMPTY set // — never "everyone", never the last good answer. Core treats a throw the // same way, but doing it here is what lets the log say which clan. // 3. It is a SET of people, not a list of characters. The `DISTINCT` is in the // query for that reason (see `clanProvider.db.js`). async function listClanMemberUserIds({ clanId }) { try { return await db.listMemberUserIds(clanId) } catch (err) { log.warn('could not resolve clan members; resolving to nobody', { clanId, message: err.message, }) return [] } } module.exports = { getTeams, getTeamMembers, getTeamLeaders, projectRoster, rosterVisibleTo, pageUrlTemplate, gameIsReachable, listClanMemberUserIds, }