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