diff --git a/server/src/auth/session.middleware.js b/server/src/auth/session.middleware.js index 1171858..93ca03e 100644 --- a/server/src/auth/session.middleware.js +++ b/server/src/auth/session.middleware.js @@ -79,6 +79,44 @@ async function requireAuth(req, res, next) { } } +// Best-effort AUTHENTICATION, as opposed to attachSession's best-effort decode. +// +// For a PUBLIC route whose content — not merely its presentation — depends on who +// is asking. The Team activity feed is the first: `public` items go to everyone +// and `members` items only to members and forum-granted users (TEAMS.md §4.3), so +// an anonymous caller must be served, not rejected, and an authenticated one must +// be identified properly. +// +// "Properly" is why this is not attachSession. That one decodes the token and +// stops, which is right for reading back your own session but wrong here: a +// banned account, a password change, or a logout would all keep working against +// the private half of the feed until the JWT expired. This runs the same +// database re-validation requireAuth does — status, cutoff, revocation — and on +// any failure continues ANONYMOUSLY rather than 401ing. A caller whose session is +// no longer good sees the public feed, which is exactly what they are entitled to. +// +// A database error also degrades to anonymous. On a public route the safe +// direction is to serve less, and 500ing a page because a session lookup failed +// would take the whole Team page down for callers who never sent a token. +async function optionalAuth(req, res, next) { + const session = sessionService.validateSession(req) + if (!session) return next() + try { + const user = await users.getById(session.userId) + if (!user) return next() + if (user.status && user.status !== 'active') return next() + if (isBeforeCutoff(session, user.tokens_valid_after)) return next() + if (await sessionService.isSessionRevoked(session.sessionId)) return next() + + req.user = user + req.session = session + req.authMethod = session.authMethod + } catch (err) { + log.warn('optionalAuth: continuing anonymously', { message: err.message }) + } + return next() +} + // Gate middleware factory: allow only the listed roles. Assumes requireAuth ran // first so req.user is populated. Use for admin-only endpoints (users, site // mode, settings) so a lower-privilege editor cannot reach them. @@ -91,6 +129,7 @@ function requireRole(...roles) { module.exports = { attachSession, + optionalAuth, requireAuth, requireRole, } diff --git a/server/src/model/teams/teamProvider.js b/server/src/model/teams/teamProvider.js index 945f983..b2253b7 100644 --- a/server/src/model/teams/teamProvider.js +++ b/server/src/model/teams/teamProvider.js @@ -163,10 +163,66 @@ function normaliseLeaders(answer) { 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() @@ -177,6 +233,7 @@ module.exports = { getTeams, getTeamMembers, getTeamLeaders, + projectRoster, providerModuleId, CALL_TIMEOUT_MS, } diff --git a/server/src/model/teams/teams.model.js b/server/src/model/teams/teams.model.js index 7f09dae..c25eff2 100644 --- a/server/src/model/teams/teams.model.js +++ b/server/src/model/teams/teams.model.js @@ -156,6 +156,16 @@ async function listPublic({ limit = 50, offset = 0 } = {}) { teams: visible.slice(offset, offset + limit).map(publicTeam), total: visible.length, ...sync, + // What the `teams` nav feature flag resolves from (§3.5). True if a provider + // is registered OR any Team exists — the second half matters because Team + // rows outlive the module that filled them, and hiding the nav entry the + // moment a module is uninstalled would make every existing Team page + // unreachable from the site while still answering by URL. + // + // False only when there is nothing and no prospect of anything, which is + // exactly the bare-core case the flag exists for: a link to a permanently + // empty page is worse than no link. + enabled: Boolean(sync.configured) || visible.length > 0, } } @@ -174,6 +184,19 @@ async function getPublic(slug) { const successor = row.succeeded_by ? await teamsDb.findById(row.succeeded_by) : null return { ...publicTeam(row), + // The three props the `team.overview` extension slot is declared with + // (§3.4). A module's slot component runs in the browser and has to know + // WHICH Team it is looking at, in its own vocabulary — `slug` is core's name + // for it and resolves nothing on the module's side. + // + // On this route only, deliberately: the index has no slot and would + // otherwise publish a module-internal identifier per row for nothing. None + // of the three names a person — they are a core row id, a game-side group + // id and a module name, and the identifiers §3.2 withholds (member keys, + // site account ids) are not among them. + id: row.id, + externalId: row.external_id, + moduleId: row.module_id, ...sync, successor: successor && !successor.hidden ? { slug: successor.slug, name: successor.display_name_override || successor.name } @@ -181,14 +204,50 @@ async function getPublic(slug) { } } -async function rosterPublic(slug) { +/** + * A Team's roster, projected for the caller's audience rung (§3.3). + * + * The ROW filter is the module's: it owns the visibility framework and its + * configuration (§10.5), and core does not know what a rung is. The FIELD shape + * stays core's — every row that survives goes through `publicMember`, which + * withholds the member key and the user id whatever the module answers. So a + * module can narrow what is published and cannot widen it, and core's "neither is + * published" guarantee does not rest on every module's good behaviour. + * + * **A module that HAS a rung system and cannot answer withholds the roster.** That + * is the one Team call where a refusal is not staleness: leaving a visibility + * answer "alone" would publish the very rows the rungs exist to withhold. A + * deployment with no module, or one whose module does not project at all, is a + * different case entirely — nothing is being withheld there, so the roster is + * served whole at core's public shape (`projects: false`). + */ +async function rosterPublic(slug, viewer = null) { const row = await teamsDb.findBySlug(slug) if (!row || row.hidden) return null const [members, sync] = await Promise.all([ access.rosterWithOverrides(row.id), syncStatus(), ]) - return { members: members.map(publicMember), ...sync, rosterSyncedAt: row.roster_synced_at } + + // The module gets the rows as it supplied them — this is its own data coming + // home — plus who is asking, which is all a rung decision needs. + const answer = await teamProvider.projectRoster(row.external_id, members, viewer) + let visible + if (answer.ok) visible = members.filter((m) => answer.members.includes(m.member_key)) + else if (answer.projects) visible = [] // fail closed: it has rungs and we could not ask + else visible = members // nothing to fail closed ABOUT + + return { + members: visible.map(publicMember), + ...sync, + rosterSyncedAt: row.roster_synced_at, + // Stated rather than implied. An empty roster has three quite different + // causes — a Team with no members, a rung that shows none, and a module that + // could not be asked — and a page that cannot tell them apart will report the + // last one as the first. + projected: answer.ok, + ...(answer.ok || !answer.projects ? {} : { projectionUnavailable: true }), + } } // ── Player ───────────────────────────────────────────────────────────────── diff --git a/server/src/modules/registries.js b/server/src/modules/registries.js index 7165f8e..e0fa24c 100644 --- a/server/src/modules/registries.js +++ b/server/src/modules/registries.js @@ -243,11 +243,22 @@ function checkLegShape(entry) { return { leg, label: label || leg, dispatch, classify } } -// All three methods are REQUIRED, with no optional half. A provider that could -// list Teams but not their members would leave core holding Teams it can never +// Three methods are REQUIRED, with no optional half. A provider that could list +// Teams but not their members would leave core holding Teams it can never // populate, and the reconciler has no sensible behaviour for that — it is not the // same as a call that fails, which is staleness and already handled (§2.4). A // module unable to answer one of the three answers `{ ok: false }` at call time. +// +// `projectRoster` is the fourth and is OPTIONAL (TEAMS.md §3.3): it expresses an +// audience model, and a module with no rung system of its own has no opinion to +// express. Omitting it means core serves rosters at its own public shape; +// implementing it means core fails CLOSED when the call cannot be made, so this +// is a member to add deliberately rather than by habit. +// +// The copy is explicit rather than a spread: this object is what core calls, so +// anything not named here is not part of the contract and must not survive +// registration. A method that silently rode along would look implemented from the +// module's side and be invisible from core's. function checkTeamProviderShape(entry) { const provider = entry || {} const out = {} @@ -257,6 +268,12 @@ function checkTeamProviderShape(entry) { } out[name] = provider[name] } + if (provider.projectRoster !== undefined) { + if (typeof provider.projectRoster !== 'function') { + throw new Error('registerTeamProvider: projectRoster must be a function if present') + } + out.projectRoster = provider.projectRoster + } return out } diff --git a/server/src/router/v1/public/teams.controller.js b/server/src/router/v1/public/teams.controller.js index 701cc9b..e92da83 100644 --- a/server/src/router/v1/public/teams.controller.js +++ b/server/src/router/v1/public/teams.controller.js @@ -5,6 +5,7 @@ // marked stale, because that is what the projection is for. const teams = require('../../../model/teams/teams.model') +const teamActivity = require('../../../model/teams/teamActivity.model') const log = require('../../../utils/logger')('teams') @@ -35,9 +36,18 @@ async function getTeam(req, res) { } } +/** + * The roster, projected for whoever is asking (§3.3). + * + * The viewer is described to the module rather than handed over: it gets the + * caller's id and role, which is what a rung decision turns on, and not the user + * row — a module has `ctx.users.getById` if it needs more, and passing the whole + * record here would make every column of `users` part of this contract. + */ async function getRoster(req, res) { try { - const roster = await teams.rosterPublic(req.params.slug) + const viewer = req.user ? { userId: req.user.id, role: req.user.role } : null + const roster = await teams.rosterPublic(req.params.slug, viewer) if (!roster) return res.status(404).json({ message: 'Team not found' }) return res.json(roster) } catch (err) { @@ -45,4 +55,28 @@ async function getRoster(req, res) { } } -module.exports = { listTeams, getTeam, getRoster } +/** + * A Team's activity feed (§4.3). + * + * The only handler in this tier that reads `req.user`, and it reads nothing else + * from the caller about what they may see: `limit` and `offset` are page + * controls, and the visibility filter is resolved from the session alone. A + * request parameter naming its own visibility is the bug the ENUM exists to + * prevent, so there is deliberately no way to ask for one. + * + * The cap is 100 rather than the index's 200 — every row carries a summary and an + * opaque payload, so a page of these is much larger than a page of Teams. + */ +async function getActivity(req, res) { + try { + const limit = Math.min(Math.max(Number.parseInt(req.query.limit, 10) || 50, 1), 100) + const offset = Math.max(Number.parseInt(req.query.offset, 10) || 0, 0) + const feed = await teamActivity.feedFor(req.params.slug, req.user ? req.user.id : null, { limit, offset }) + if (!feed) return res.status(404).json({ message: 'Team not found' }) + return res.json(feed) + } catch (err) { + return fail(res, err, 'activity') + } +} + +module.exports = { listTeams, getTeam, getRoster, getActivity } diff --git a/server/src/router/v1/public/teams.router.js b/server/src/router/v1/public/teams.router.js index 3b17d25..71f12ef 100644 --- a/server/src/router/v1/public/teams.router.js +++ b/server/src/router/v1/public/teams.router.js @@ -12,6 +12,7 @@ const express = require('express') const ctrl = require('./teams.controller') const siteMode = require('../../../middleware/siteMode') +const { optionalAuth } = require('../../../auth/session.middleware') const teamsRouter = express.Router() @@ -43,12 +44,34 @@ teamsRouter.get( '/:slug/members', // #swagger.tags = ['Public · Teams'] // #swagger.summary = 'Get a Team roster' - // #swagger.description = 'In-game display names only. A member key is a game-internal identifier and a user id names a site account; neither is published. `linked` answers whether a character has an account behind it without saying which.' + // #swagger.description = 'In-game display names only. A member key is a game-internal identifier and a user id names a site account; neither is published, whatever the module’s projection answers. `linked` answers whether a character has an account behind it without saying which. WHICH rows appear is the module’s audience projection; sending a session is optional and may widen it.' // #swagger.parameters['slug'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'The Team slug.' } + // #swagger.security = [{}, { "cookieAuth": [] }, { "bearerAuth": [] }] /* #swagger.responses[200] = { description: 'The roster, with sync freshness', content: { "application/json": { schema: { $ref: "#/components/schemas/PublicTeamRoster" } } } } */ /* #swagger.responses[404] = { description: 'No such Team, or it is hidden', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ siteMode, + optionalAuth, ctrl.getRoster, ) +// The one route in this tier that reads the caller's identity. `optionalAuth` +// serves anonymous callers rather than rejecting them, and identifies an +// authenticated one properly enough that a banned or logged-out account drops +// back to the public half of the feed at once (TEAMS.md §4.3). +teamsRouter.get( + '/:slug/activity', + // #swagger.tags = ['Public · Teams'] + // #swagger.summary = 'A Team’s activity feed, filtered to what the caller may see' + // #swagger.description = 'Items are `public` or `members`. Anyone who can see the Team gets the public ones; members and forum-granted users also get the members-only ones, and the response says which via `scope` so a client can render "some items are hidden" rather than presenting a filtered feed as the whole one. Sending a session is optional.' + // #swagger.parameters['slug'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'The Team slug.' } + // #swagger.parameters['limit'] = { in: 'query', required: false, schema: { type: 'integer' }, description: 'Page size, max 100 (default 50).' } + // #swagger.parameters['offset'] = { in: 'query', required: false, schema: { type: 'integer' }, description: 'Rows to skip (default 0).' } + // #swagger.security = [{}, { "cookieAuth": [] }, { "bearerAuth": [] }] + /* #swagger.responses[200] = { description: 'One page of the feed', content: { "application/json": { schema: { $ref: "#/components/schemas/PublicTeamActivity" } } } } */ + /* #swagger.responses[404] = { description: 'No such Team, or it is hidden from this caller', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ + siteMode, + optionalAuth, + ctrl.getActivity, +) + module.exports = teamsRouter diff --git a/server/swagger/swagger.js b/server/swagger/swagger.js index 03ebf40..47731ee 100644 --- a/server/swagger/swagger.js +++ b/server/swagger/swagger.js @@ -979,6 +979,12 @@ const doc = { properties: { teams: { type: 'array', items: { $ref: '#/components/schemas/PublicTeam' } }, total: { type: 'integer', example: 12 }, + enabled: { + type: 'boolean', + description: + 'Whether this deployment has Teams at all — a provider is registered, or Teams exist from one that since went away. The `teams` nav feature flag resolves from this; false means bare core, where a Teams link would lead to a permanently empty page.', + example: true, + }, }, }, PublicTeamMember: { @@ -999,6 +1005,41 @@ const doc = { properties: { members: { type: 'array', items: { $ref: '#/components/schemas/PublicTeamMember' } }, rosterSyncedAt: { type: 'string', format: 'date-time', nullable: true }, + projected: { + type: 'boolean', + description: + 'Whether the module applied its own audience projection to this roster. False means the module declined or does not project, and the roster was served at core’s public shape — never the full one.', + example: true, + }, + }, + }, + PublicTeamActivityItem: { + type: 'object', + description: + '`summary` is already-rendered text supplied by whoever pushed the item; core never composes one. `kind` and `payload` are opaque to core — only the module’s `team.overview` slot renders anything richer than the text.', + properties: { + id: { type: 'integer', example: 4821 }, + source: { type: 'string', description: '`core` or a module id.', example: 'uo' }, + kind: { type: 'string', example: 'uo.champion.completed' }, + summary: { type: 'string', example: 'Completed Champion Neira' }, + visibility: { type: 'string', enum: ['public', 'members'] }, + occurredAt: { type: 'string', format: 'date-time' }, + payload: { type: 'object', nullable: true, additionalProperties: true }, + }, + }, + PublicTeamActivity: { + type: 'object', + properties: { + items: { type: 'array', items: { $ref: '#/components/schemas/PublicTeamActivityItem' } }, + total: { type: 'integer', description: 'Matching rows for THIS caller’s visibility, so paging is honest.', example: 137 }, + limit: { type: 'integer', example: 50 }, + offset: { type: 'integer', example: 0 }, + scope: { + type: 'string', + enum: ['public', 'members'], + description: + 'Which visibilities this caller received. `public` means members-only items were withheld — render that fact rather than presenting a filtered feed as the whole one.', + }, }, }, PlayerTeamList: { diff --git a/server/test/teamProvider.test.js b/server/test/teamProvider.test.js index 75bdd5b..f160d6b 100644 --- a/server/test/teamProvider.test.js +++ b/server/test/teamProvider.test.js @@ -287,3 +287,90 @@ test('a hung call does not hold the process open until its deadline', async () = test('the budget is the documented ten seconds', () => { assert.equal(teamProvider.CALL_TIMEOUT_MS, 10_000) }) + +// ── projectRoster: the optional fourth member (§3.3) ─────────────────────── +// +// The one Team call where a refusal must NOT be treated as staleness. Every test +// below exists because the obvious implementation — reuse `call()` and serve the +// roster when it fails — silently publishes the rows the rungs exist to withhold. + +const rows = [{ member_key: '0x1' }, { member_key: '0x2' }] + +test('projectRoster is optional: a provider without it registers fine', () => { + const api = registries.stage('uo') + assert.doesNotThrow(() => api.registerTeamProvider(ok())) +}) + +test('a non-function projectRoster is rejected at registration, not at call time', () => { + const api = registries.stage('uo') + assert.throws( + () => api.registerTeamProvider({ ...ok(), projectRoster: 'yes please' }), + /projectRoster must be a function/, + ) +}) + +test('an unregistered method cannot ride along into the provider core calls', () => { + register('uo', { ...ok(), somethingElse: async () => 'hi' }) + assert.equal(registries.registeredTeamProvider().somethingElse, undefined) +}) + +test('no provider at all is projects:false — nothing is being withheld', async () => { + const answer = await teamProvider.projectRoster('g1', rows, null) + assert.equal(answer.ok, false) + assert.equal(answer.projects, false) +}) + +test('a provider that does not project is projects:false, not a failure to fear', async () => { + register('uo', ok()) + const answer = await teamProvider.projectRoster('g1', rows, null) + assert.equal(answer.ok, false) + assert.equal(answer.projects, false) +}) + +test('a provider that HAS projectRoster and refuses is projects:true — the caller must fail closed', async () => { + register('uo', { ...ok(), projectRoster: async () => ({ ok: false, reason: 'atlas not loaded' }) }) + const answer = await teamProvider.projectRoster('g1', rows, null) + assert.equal(answer.ok, false) + assert.equal(answer.projects, true) + assert.equal(answer.reason, 'atlas not loaded') +}) + +test('a projectRoster that throws is projects:true as well — a bug is not permission', async () => { + register('uo', { ...ok(), projectRoster: async () => { throw new Error('boom') } }) + const answer = await teamProvider.projectRoster('g1', rows, null) + assert.equal(answer.projects, true) +}) + +test('the module receives the rows and the viewer, and answers with member keys', async () => { + let seen + register('uo', { + ...ok(), + projectRoster: async (externalId, members, viewer) => { + seen = { externalId, members, viewer } + return { ok: true, members: ['0x2'] } + }, + }) + const answer = await teamProvider.projectRoster('g1', rows, { userId: 7, role: 'player' }) + assert.deepEqual(seen.members, rows) + assert.deepEqual(seen.viewer, { userId: 7, role: 'player' }) + assert.equal(seen.externalId, 'g1') + assert.deepEqual(answer.members, ['0x2']) +}) + +test('a malformed key list is a refusal, so the caller fails closed rather than serving garbage', async () => { + for (const bad of [{ ok: true }, { ok: true, members: ['ok', ''] }, { ok: true, members: 'all' }]) { + // eslint-disable-next-line no-await-in-loop + register('uo', { ...ok(), projectRoster: async () => bad }) + // eslint-disable-next-line no-await-in-loop + const answer = await teamProvider.projectRoster('g1', rows, null) + assert.equal(answer.ok, false, JSON.stringify(bad)) + assert.equal(answer.projects, true) + registries._reset() + } +}) + +test('duplicate keys are collapsed', async () => { + register('uo', { ...ok(), projectRoster: async () => ({ ok: true, members: ['0x1', '0x1', '0x2'] }) }) + const answer = await teamProvider.projectRoster('g1', rows, null) + assert.deepEqual(answer.members, ['0x1', '0x2']) +}) diff --git a/server/test/teamRoster.test.js b/server/test/teamRoster.test.js new file mode 100644 index 0000000..a0b45ba --- /dev/null +++ b/server/test/teamRoster.test.js @@ -0,0 +1,122 @@ +// The roster read and its audience projection (docs/website/TEAMS.md §3.2, §3.3). +// +// Two questions meet here and the file exists to keep them apart: +// +// WHICH ROWS is the module's — it owns the visibility framework and its rung +// configuration, and core does not know what a rung is. +// WHAT A ROW is core's — the member key and the user id are never published, +// LOOKS LIKE whatever the module answers. +// +// The dangerous simplification is to let the module return rows instead of keys: +// core's field guarantee would then rest on every module's good behaviour rather +// than on core, and one module re-adding a `userId` would publish site accounts +// against in-game characters on a public page. +const { test, beforeEach, afterEach } = require('node:test') +const assert = require('node:assert/strict') + +const teamsDb = require('../src/model/teams/teams.db') +const access = require('../src/model/teams/teamAccess.model') +const teamProvider = require('../src/model/teams/teamProvider') +const teamSync = require('../src/model/teams/teamSync.model') +const teams = require('../src/model/teams/teams.model') + +const saved = new Map() + +function patch(mod, name, fn) { + if (!saved.has(mod)) saved.set(mod, new Map()) + if (!saved.get(mod).has(name)) saved.get(mod).set(name, mod[name]) + mod[name] = fn +} + +function restore() { + for (const [mod, names] of saved) for (const [name, fn] of names) mod[name] = fn + saved.clear() +} + +const ROWS = [ + { member_key: '0x1', display_name: 'Aldric', user_id: 7, is_leader: 1, rank_label: 'Leader', online: 1 }, + { member_key: '0x2', display_name: 'Brenna', user_id: null, is_leader: 0, rank_label: null, online: 0 }, + { member_key: '0x3', display_name: 'Cadfael', user_id: 9, is_leader: 0, rank_label: null, online: 0 }, +] + +beforeEach(() => { + patch(teamsDb, 'findBySlug', async (slug) => + (slug === 'the-guild' + ? { id: 1, external_id: 'g1', slug, hidden: 0, status: 'active', roster_synced_at: null } + : undefined)) + patch(access, 'rosterWithOverrides', async () => ROWS.map((r) => ({ ...r }))) + // syncStatus() reads sync state and the poll interval; neither is what this + // file is about, and both would otherwise reach the pool. + patch(teamProvider, 'providerModuleId', () => null) + patch(teamSync, 'intervalSeconds', async () => 900) +}) +afterEach(restore) + +test('with no module projecting, the whole roster is served at core\'s public shape', async () => { + patch(teamProvider, 'projectRoster', async () => ({ ok: false, projects: false, reason: 'no provider' })) + const roster = await teams.rosterPublic('the-guild', null) + assert.equal(roster.members.length, 3) + assert.equal(roster.projected, false) + assert.equal(roster.projectionUnavailable, undefined, 'nothing was withheld, so nothing to report') +}) + +test('the module chooses which rows a viewer sees', async () => { + patch(teamProvider, 'projectRoster', async () => ({ ok: true, members: ['0x2'] })) + const roster = await teams.rosterPublic('the-guild', null) + assert.deepEqual(roster.members.map((m) => m.displayName), ['Brenna']) + assert.equal(roster.projected, true) +}) + +test('a module that projects but cannot answer withholds the roster — it does not serve it', async () => { + // The whole point. "Leave it alone" is right for a roster SYNC and wrong for a + // visibility question: it would publish exactly what the rungs withhold. + patch(teamProvider, 'projectRoster', async () => ({ ok: false, projects: true, reason: 'sidecar down' })) + const roster = await teams.rosterPublic('the-guild', null) + assert.deepEqual(roster.members, []) + assert.equal(roster.projected, false) + assert.equal(roster.projectionUnavailable, true, 'an empty roster must be distinguishable from a silent one') +}) + +test('the module cannot widen the published fields, only narrow the rows', async () => { + // A module answering with keys it was given still yields core's shape. There is + // no answer it can give that puts a member key or a user id on a public page. + patch(teamProvider, 'projectRoster', async () => ({ ok: true, members: ['0x1', '0x2', '0x3'] })) + const roster = await teams.rosterPublic('the-guild', null) + for (const member of roster.members) { + assert.deepEqual( + Object.keys(member).sort(), + ['displayName', 'isLeader', 'linked', 'online', 'rankLabel'], + 'the public member shape is core\'s and is closed', + ) + } + assert.deepEqual(roster.members.map((m) => m.linked), [true, false, true]) +}) + +test('a key the module invents matches nothing rather than adding a row', async () => { + patch(teamProvider, 'projectRoster', async () => ({ ok: true, members: ['0x1', '0xNOPE'] })) + const roster = await teams.rosterPublic('the-guild', null) + assert.equal(roster.members.length, 1) +}) + +test('the viewer is described to the module, not handed over', async () => { + let seen + patch(teamProvider, 'projectRoster', async (externalId, members, viewer) => { + seen = viewer + return { ok: true, members: members.map((m) => m.member_key) } + }) + await teams.rosterPublic('the-guild', { userId: 7, role: 'player' }) + assert.deepEqual(seen, { userId: 7, role: 'player' }) +}) + +test('an unknown slug is not found, and the module is never consulted about it', async () => { + let called = false + patch(teamProvider, 'projectRoster', async () => { called = true; return { ok: true, members: [] } }) + assert.equal(await teams.rosterPublic('no-such-team', null), null) + assert.equal(called, false) +}) + +test('a hidden team\'s roster does not answer publicly at all', async () => { + patch(teamsDb, 'findBySlug', async () => ({ id: 1, external_id: 'g1', slug: 'x', hidden: 1, status: 'active' })) + patch(teamProvider, 'projectRoster', async () => ({ ok: true, members: ['0x1'] })) + assert.equal(await teams.rosterPublic('x', null), null) +})