TEAMS.md §3.3, as the eighth member of MODULE_API 1.6.0 — amended in place per the org lead, on the rule Protocol 4 was given in phase 2: a contract owes a bump only once it has landed on `main`. Two questions meet on the roster and they belong to different owners. WHICH ROWS a viewer may see is the module's, because the audience rungs and their configuration live there and core does not know what a rung is. WHAT A ROW LOOKS LIKE stays core's. So `projectRoster` answers with member KEYS, not rows. §3.3 said rows, and rows would let a module widen what is published — handing back a `userId` core had withheld — leaving core's field guarantee resting on every module's good behaviour. Core asks which rows and re-normalises the answer through its own public shape, so a module can narrow and cannot widen. "The module declines" needed splitting before it could be implemented. No module at all and a module whose rungs could not be consulted are opposite situations: the first withholds nothing and must serve the roster whole, the second must serve none of it. The refusal carries `projects`, and only `projects: true` fails closed. Without the split, bare core serves an empty roster on every Team page. This is also the first public route whose CONTENT depends on identity, which needed a middleware core did not have. `attachSession` only decodes a token, so a banned account, a password change or a logout would have kept working against the private half of a feed until the JWT expired. `optionalAuth` runs requireAuth's full database re-validation and, on any failure, continues ANONYMOUSLY rather than rejecting — a caller whose session is no longer good sees the public view, which is what they are entitled to. `GET /public/teams/:slug/activity` lands here for the same reason: §2.11's route table had no activity endpoint though §4.3 describes a filtered feed. Paged, with the visibility resolved from the session and never from a parameter. Co-Authored-By: Claude <noreply@anthropic.com>
240 lines
11 KiB
JavaScript
240 lines
11 KiB
JavaScript
// ── Calling the Team provider ──────────────────────────────────────────────
|
|
//
|
|
// The one place core asks a module a question and waits for the answer
|
|
// (docs/website/TEAMS.md §2.3). Everything here exists to serve invariant 1:
|
|
//
|
|
// **Module unavailability is staleness, never emptiness.**
|
|
//
|
|
// No Team subsystem may apply a destructive result derived from a failed,
|
|
// timed-out or unanswered module call. This file is where "failed" is defined, and
|
|
// it is deliberately generous about what counts: a rejected promise, a timeout, a
|
|
// non-object, a missing `ok`, or a structurally malformed row all leave with the
|
|
// same `{ ok: false }` the module would have sent deliberately.
|
|
//
|
|
// **There is no shape a failure can take that core reads as "zero teams".** That
|
|
// is the whole argument for the envelope, and the reason the provider signature is
|
|
// not the obvious `getTeams(): Team[]` — a bare array has exactly one such shape,
|
|
// `[]`, and it is the one a module returns while its sidecar is still connecting.
|
|
//
|
|
// Nothing here touches the database. It calls the module and hands back a value
|
|
// the reconciler can trust the SHAPE of; whether to ACT on it is §2.4's question.
|
|
|
|
const registries = require('../../modules/registries')
|
|
const log = require('../../utils/logger')('teams')
|
|
|
|
// The budget from §2.3. A provider is answering from its own cache or its own
|
|
// sidecar client, both of which have their own timeouts well inside this; a call
|
|
// that reaches ten seconds is wedged, not slow.
|
|
const CALL_TIMEOUT_MS = 10_000
|
|
|
|
/** A uniform refusal. `reason` is for the operator, via team_sync_state. */
|
|
const fail = (reason) => ({ ok: false, reason })
|
|
|
|
/**
|
|
* Await `promise` with a timeout that cannot outlive the call.
|
|
*
|
|
* The timer is always cleared — including on the winning path — because an
|
|
* uncleared 10s timer holds the event loop open, which in a test run means the
|
|
* process hangs long after the assertions passed. The suite already learned this
|
|
* one from a mariadb pool (test/_setup.js).
|
|
*
|
|
* It is also `unref`ed, which covers the case clearing cannot: when the module's
|
|
* promise NEVER settles, the race stays pending and there is nothing to clear
|
|
* until the deadline fires. An unreffed timer still fires normally while the
|
|
* process is alive — the server's own listener is what keeps it alive — but it no
|
|
* longer holds a shutdown open for ten seconds waiting on a module that is not
|
|
* going to answer.
|
|
*/
|
|
function withTimeout(promise, ms) {
|
|
let timer
|
|
const timeout = new Promise((resolve) => {
|
|
timer = setTimeout(() => resolve(fail(`provider did not answer within ${ms}ms`)), ms)
|
|
if (typeof timer.unref === 'function') timer.unref()
|
|
})
|
|
return Promise.race([promise, timeout]).finally(() => clearTimeout(timer))
|
|
}
|
|
|
|
/**
|
|
* Call one provider method and normalise whatever comes back into an envelope.
|
|
*
|
|
* `normalise` is only ever run on an `ok` answer, and may itself return a refusal
|
|
* — a structurally malformed row is treated as a failed call rather than as data
|
|
* to salvage. Salvaging is the dangerous option: dropping one unreadable member
|
|
* from a roster is indistinguishable, downstream, from that member having left,
|
|
* and the sync would mark them departed. Refusing costs one stale interval.
|
|
*/
|
|
async function call(method, normalise, ...args) {
|
|
const provider = registries.registeredTeamProvider()
|
|
if (!provider) return fail('no team provider is registered')
|
|
|
|
let answer
|
|
try {
|
|
answer = await withTimeout(Promise.resolve().then(() => provider[method](...args)), CALL_TIMEOUT_MS)
|
|
} catch (err) {
|
|
// A rejected promise is a module that threw, which is exactly as
|
|
// unauthoritative as one that answered `{ ok: false }`.
|
|
return fail(`${method}() threw: ${err.message}`)
|
|
}
|
|
|
|
if (!answer || typeof answer !== 'object' || Array.isArray(answer)) {
|
|
return fail(`${method}() returned ${Array.isArray(answer) ? 'an array' : typeof answer}, not an envelope`)
|
|
}
|
|
// `ok` must be present and true. A module that forgot the field is not one
|
|
// asserting authority, and reading a missing field as truthy would put the
|
|
// single most consequential decision in this file on a typo.
|
|
if (answer.ok !== true) return fail(answer.reason || `${method}() answered not-ok`)
|
|
|
|
const normalised = normalise(answer)
|
|
if (normalised.ok === false) {
|
|
log.warn('team provider answered with a malformed payload', {
|
|
owner: provider.owner, method, reason: normalised.reason,
|
|
})
|
|
}
|
|
return normalised
|
|
}
|
|
|
|
// `complete` defaults to TRUE when the module omits it, matching §2.3: the
|
|
// envelope's optional field marks a partial answer, so its absence is the
|
|
// ordinary authoritative case. A module that cannot enumerate exhaustively says
|
|
// so explicitly.
|
|
const isComplete = (answer) => answer.complete !== false
|
|
|
|
const str = (v) => (typeof v === 'string' ? v.trim() : '')
|
|
|
|
/** `{ ok, complete, teams: [{ externalId, name, abbr, meta }] }` */
|
|
function normaliseTeams(answer) {
|
|
if (!Array.isArray(answer.teams)) return fail('getTeams() answered ok with no teams array')
|
|
const teams = []
|
|
for (const raw of answer.teams) {
|
|
const externalId = str(raw && raw.externalId)
|
|
const name = str(raw && raw.name)
|
|
// Both are load-bearing and neither has a safe default: externalId is the
|
|
// identity the whole rename rule (§2.2) turns on, and a Team with no name has
|
|
// no slug and no page.
|
|
if (!externalId) return fail('a team in getTeams() has no externalId')
|
|
if (!name) return fail(`team "${externalId}" has no name`)
|
|
teams.push({
|
|
externalId,
|
|
name,
|
|
abbr: str(raw.abbr) || null,
|
|
// Opaque by contract (§10.5) — stored and handed back, never branched on.
|
|
meta: raw.meta && typeof raw.meta === 'object' ? raw.meta : null,
|
|
})
|
|
}
|
|
return { ok: true, complete: isComplete(answer), teams }
|
|
}
|
|
|
|
/** `{ ok, complete, members: [{ memberKey, displayName, rankLabel, leader, online, userId }] }` */
|
|
function normaliseMembers(answer) {
|
|
if (!Array.isArray(answer.members)) return fail('getTeamMembers() answered ok with no members array')
|
|
const members = []
|
|
const seen = new Set()
|
|
for (const raw of answer.members) {
|
|
const memberKey = str(raw && raw.memberKey)
|
|
if (!memberKey) return fail('a member has no memberKey')
|
|
// A duplicate key would upsert twice and inflate no count but confuse every
|
|
// reader; it also means the module's own identity rule is broken, which is
|
|
// worth surfacing rather than quietly collapsing.
|
|
if (seen.has(memberKey)) return fail(`member "${memberKey}" appears twice`)
|
|
seen.add(memberKey)
|
|
members.push({
|
|
memberKey,
|
|
displayName: str(raw.displayName) || null,
|
|
rankLabel: str(raw.rankLabel) || null,
|
|
leader: Boolean(raw.leader),
|
|
online: Boolean(raw.online),
|
|
// Resolved BY THE MODULE — it owns the game↔site link table (§2.3). Core
|
|
// takes the number and never looks it up.
|
|
userId: Number.isInteger(raw.userId) && raw.userId > 0 ? raw.userId : null,
|
|
})
|
|
}
|
|
return { ok: true, complete: isComplete(answer), members }
|
|
}
|
|
|
|
/** `{ ok, leaders: [memberKey] }` */
|
|
function normaliseLeaders(answer) {
|
|
if (!Array.isArray(answer.leaders)) return fail('getTeamLeaders() answered ok with no leaders array')
|
|
const leaders = []
|
|
for (const raw of answer.leaders) {
|
|
const key = str(raw)
|
|
if (!key) return fail('a leader entry is not a member key')
|
|
if (!leaders.includes(key)) leaders.push(key)
|
|
}
|
|
return { ok: true, leaders }
|
|
}
|
|
|
|
/**
|
|
* `{ ok, members: [memberKey] }` — WHICH rows the module permits this viewer.
|
|
*
|
|
* Deliberately a set of keys rather than a set of rows. Core already holds the
|
|
* rows and knows their public shape; asking the module for rows back would let a
|
|
* module widen what is published — re-adding a `userId` or a `memberKey` that
|
|
* §3.2 says is never published — and core's field guarantee would then rest on
|
|
* every module's good behaviour rather than on core. So the module answers the
|
|
* question it actually owns (who may be seen at this rung) and core keeps the
|
|
* question it owns (what a member row looks like in public).
|
|
*/
|
|
function normaliseVisibleKeys(answer) {
|
|
if (!Array.isArray(answer.members)) return fail('projectRoster() answered ok with no members array')
|
|
const keys = []
|
|
for (const raw of answer.members) {
|
|
const key = str(raw)
|
|
if (!key) return fail('a projectRoster() entry is not a member key')
|
|
if (!keys.includes(key)) keys.push(key)
|
|
}
|
|
return { ok: true, members: keys }
|
|
}
|
|
|
|
const getTeams = () => call('getTeams', normaliseTeams)
|
|
const getTeamMembers = (externalId) => call('getTeamMembers', normaliseMembers, externalId)
|
|
const getTeamLeaders = (externalId) => call('getTeamLeaders', normaliseLeaders, externalId)
|
|
|
|
/**
|
|
* Ask the module which roster rows this viewer may see (§3.3).
|
|
*
|
|
* The per-audience projection is the module's because the visibility framework
|
|
* and its rung configuration are module-owned (§10.5) — core does not know what a
|
|
* rung is. Core supplies the roster and a description of the viewer; the module
|
|
* returns the member keys it permits.
|
|
*
|
|
* **"No audience model" and "could not answer" are different, and the caller must
|
|
* be able to tell them apart** — so the refusal carries `projects`.
|
|
*
|
|
* `projects: false` — no provider is registered, or the registered one does not
|
|
* implement `projectRoster`. There is no rung system to consult and nothing
|
|
* is being withheld; the roster is served at core's public shape. This is why
|
|
* the member is OPTIONAL: bare core, and a module with no audience model of
|
|
* its own, both render exactly the page core writes.
|
|
*
|
|
* `projects: true` — the module HAS an audience model and core could not reach
|
|
* it (refused, threw, timed out, answered malformed). Here the caller must
|
|
* fail CLOSED, because "leave it alone" would mean publishing the very rows
|
|
* the rungs exist to withhold. This is the one place in the Team subsystem
|
|
* where unavailability is not staleness: everywhere else a refused call
|
|
* leaves data alone, and doing that to a *visibility* question is a leak.
|
|
*/
|
|
async function projectRoster(externalId, members, viewer) {
|
|
const provider = registries.registeredTeamProvider()
|
|
if (!provider) return { ...fail('no team provider is registered'), projects: false }
|
|
if (typeof provider.projectRoster !== 'function') {
|
|
return { ...fail('provider does not project rosters'), projects: false }
|
|
}
|
|
const answer = await call('projectRoster', normaliseVisibleKeys, externalId, members, viewer)
|
|
return answer.ok ? answer : { ...answer, projects: true }
|
|
}
|
|
|
|
/** Which module is authoritative, or null. The reconciler keys sync state on it. */
|
|
const providerModuleId = () => {
|
|
const provider = registries.registeredTeamProvider()
|
|
return provider ? provider.owner : null
|
|
}
|
|
|
|
module.exports = {
|
|
getTeams,
|
|
getTeamMembers,
|
|
getTeamLeaders,
|
|
projectRoster,
|
|
providerModuleId,
|
|
CALL_TIMEOUT_MS,
|
|
}
|