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:
2026-08-17 20:15:36 -05:00
parent aa332eda82
commit 03631d7d40
9 changed files with 486 additions and 7 deletions

View File

@@ -79,6 +79,44 @@ async function requireAuth(req, res, next) {
}
}
// Best-effort AUTHENTICATION, as opposed to attachSession's best-effort decode.
//
// For a PUBLIC route whose content — not merely its presentation — depends on who
// is asking. The Team activity feed is the first: `public` items go to everyone
// and `members` items only to members and forum-granted users (TEAMS.md §4.3), so
// an anonymous caller must be served, not rejected, and an authenticated one must
// be identified properly.
//
// "Properly" is why this is not attachSession. That one decodes the token and
// stops, which is right for reading back your own session but wrong here: a
// banned account, a password change, or a logout would all keep working against
// the private half of the feed until the JWT expired. This runs the same
// database re-validation requireAuth does — status, cutoff, revocation — and on
// any failure continues ANONYMOUSLY rather than 401ing. A caller whose session is
// no longer good sees the public feed, which is exactly what they are entitled to.
//
// A database error also degrades to anonymous. On a public route the safe
// direction is to serve less, and 500ing a page because a session lookup failed
// would take the whole Team page down for callers who never sent a token.
async function optionalAuth(req, res, next) {
const session = sessionService.validateSession(req)
if (!session) return next()
try {
const user = await users.getById(session.userId)
if (!user) return next()
if (user.status && user.status !== 'active') return next()
if (isBeforeCutoff(session, user.tokens_valid_after)) return next()
if (await sessionService.isSessionRevoked(session.sessionId)) return next()
req.user = user
req.session = session
req.authMethod = session.authMethod
} catch (err) {
log.warn('optionalAuth: continuing anonymously', { message: err.message })
}
return next()
}
// Gate middleware factory: allow only the listed roles. Assumes requireAuth ran
// first so req.user is populated. Use for admin-only endpoints (users, site
// mode, settings) so a lower-privilege editor cannot reach them.
@@ -91,6 +129,7 @@ function requireRole(...roles) {
module.exports = {
attachSession,
optionalAuth,
requireAuth,
requireRole,
}