feat(teams): the roster's audience projection, and optionalAuth to resolve it
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>
This commit is contained in:
@@ -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'])
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user