The kit was pinned to website 963d734 -- MODULE_API 1.6.0, the Teams cutover --
and the platform is on 1.9.0. Three registrations and two calls arrived in
between, and a reader building against this book would have found no mention of
any of them: a module can now declare what its game can announce, and never who
is told.
Moving `ci/core-ref.json` is the mechanism for exactly this. The pin is now
66bb3b9a (website `main`, the engagement cutover) and `template/module.json`
declares `^1.9.0`.
What chapter 2 gained, under "Telling core something happened":
* a TRIGGER is a payload contract, not a notification stream -- the two share
one id namespace and are constantly confused;
* `ceiling` is required, has no default, and is a CONTAINMENT tree rather than
a size ladder (a `staff` ceiling does not permit `owner`);
* an AUDIENCE resolver returns user ids and nothing else, resolves to NOBODY
on failure, and takes CONSTANT params -- the constraint worth knowing before
you design around it;
* templates re-ensure per seedVersion, rule groups are offered ONCE per group
key, so a rule appended to an existing group reaches fresh installs only;
* `ctx.events.emit` binds the owner and is fire-and-forget; `ctx.inbox.push`
is the direct write, for when there is nothing for an operator to decide.
The template builds all of it: one trigger, one audience over the clan roster it
already had, one seeded body and one seeded rule group, and an emitter in
`boot.js` that fires on the TRANSITION rather than on the poll. Seven new tests,
including the audience that resolves to nobody when its query throws.
Three claims were wrong and are corrected here rather than shipped:
* core validates `subjectKey` against the declared variables and refuses the
module; the draft taught a cooldown keyed on `undefined`, which the check
exists to prevent and a reader will never see.
* `emit` throws OUTSIDE production and only drops-and-logs inside it. Teaching
the second half alone leaves a developer meeting a throw the book says
cannot happen.
* the seeded body itself was malformed -- heading `level: 2` where the block
registry takes 'h2', and no block ids at all.
The third is the one worth keeping: `registerEngagementSeeds` checks that
`blocks` is a non-empty array and stops, so that body would have registered,
seeded, and failed the first time an operator opened it. Found by running the
template's `register()` through core's real registry at the pinned ref -- which
CI does not do, and cannot: the template job checks the version and runs the
template against fakes. A fake accepts what core refuses. The gap is now named
in the chapter, beside the code, and in the pin's own comment, and the rule that
bit has a test that fails on it.
Also: `checkLinks` skipped `.core/`. Bumping this pin means cloning core into
that directory first, and the walk then reported nine broken links in someone
else's README. CI never saw it -- the clone happens in the `template` job and
the check runs in `prose` -- so it was a failure only a person could meet.
Co-Authored-By: Claude <noreply@anthropic.com>
335 lines
16 KiB
JavaScript
335 lines
16 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}'
|
|
|
|
// ── 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,
|
|
}
|