Files
website/server/src/router/v1/public/teams.controller.js
wtclaude 03631d7d40 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>
2026-08-17 20:15:36 -05:00

83 lines
3.2 KiB
JavaScript

// Public · Teams — the anonymous read surface (TEAMS.md §2.11).
//
// Every handler here is a projection over core's own tables; nothing calls the
// module. A Team page must render while the shard is down, showing a roster
// 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')
const fail = (res, err, what) => {
log.error(`public teams: ${what} failed`, { message: err.message })
return res.status(500).json({ message: 'Internal Server Error' })
}
async function listTeams(req, res) {
try {
const limit = Math.min(Number.parseInt(req.query.limit, 10) || 50, 200)
const offset = Math.max(Number.parseInt(req.query.offset, 10) || 0, 0)
return res.json(await teams.listPublic({ limit, offset }))
} catch (err) {
return fail(res, err, 'list')
}
}
async function getTeam(req, res) {
try {
const team = await teams.getPublic(req.params.slug)
// A hidden Team is indistinguishable from a missing one here, deliberately:
// "absent from every public surface" includes not confirming it exists.
if (!team) return res.status(404).json({ message: 'Team not found' })
return res.json(team)
} catch (err) {
return fail(res, err, 'get')
}
}
/**
* 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 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) {
return fail(res, err, 'roster')
}
}
/**
* 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 }