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:
@@ -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,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user