Files
Integration-kit/template/server/model/clans/clanProvider.model.js
wtclaude 7875848ee7 feat(kit): the two shapes Teams added, taught and built
MODULE_API 1.6.0 expands the contract this book teaches against, so the book
owes two shapes and one correction. Chapter 2 gains both and the template grows
a working version of each, because a reader following a snippet has no way to
find out whether it runs.

ONE SENTENCE WAS WRONG. Chapter 2 said, of extension slots, "Only core may
declare a slot; a module may only fill one". 1.6.0 inverted exactly that: a
module declares a place on its OWN page and core fills it. That is not a stale
detail - a new game's module cannot implement Teams at all without the inverted
direction, so it is the shape the reader needs and did not have.

THE TWO SHAPES

- The inverted slot. A new "Slots go the other way too" section: why the
  direction has to invert (core owns the Team, not the word for one), the
  namespace rule, one slot per PLACE, the optional { core } naming which of
  core's three contributions goes there, and why asking for one core does not
  offer throws when almost everything else in that registry fails open.

- registerTeamProvider, in "Becoming the source of Teams". The first
  registration where core calls YOU and waits, which is where every rule in it
  comes from: the envelope, the ten-second budget, refusing as a normal answer,
  and the one mistake worth naming - answering with an empty list because the
  game is unreachable, which core reads as authoritative and acts on.
  projectRoster gets its own treatment because it is the exception that fails
  CLOSED. pageUrlTemplate is a footnote beside it, as intended.

WHAT THE TEMPLATE GREW

model/clans/ - the provider over two tables, with the guards that matter: an
unreachable game refuses rather than reporting no clans, an empty roster is
refused unless the game says the clan is empty (which is why the schema keeps a
member count the rows cannot supply), and the audience rule lives in one file
that both projectRoster and the module's own page consult, because a second copy
drifts in the direction that publishes what core is withholding.

Its own /clans routes, deliberately not /teams - core mounts that itself, and
the loader would refuse the collision. A clan list page and a clan page that
declares three slots for core.

12 provider tests and three registration tests, 47 server and 20 client in
total. The purge test finally proves something: two of the three tables are now
a parent and its child.

WHAT IT DOES NOT DO. Enumerate the contract. The kit teaches one path end to end
and links out; it has never mentioned three pre-Teams registrations and that is
the design, not a gap.

FOUND WHILE WRITING IT: core filled three literal uo.guild.* slot names, so the
inverted direction reached exactly one module and every other game's page came
up empty with nothing logged. Fixed in website#160 / Module-uo#15 / docs#165
before this chapter could teach it - which is what this phase is for.

The ci/core-ref.json pin moves in a later commit on this branch: checkCoreApi is
an equality against a core on main, and 1.6.0 does not reach main until the
cutover.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-19 01:21:55 -05:00

304 lines
14 KiB
JavaScript

// ── 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}'
module.exports = {
getTeams,
getTeamMembers,
getTeamLeaders,
projectRoster,
rosterVisibleTo,
pageUrlTemplate,
gameIsReachable,
}