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>
123 lines
5.7 KiB
JavaScript
123 lines
5.7 KiB
JavaScript
// 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)
|
|
})
|