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