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>
136 lines
6.3 KiB
JavaScript
136 lines
6.3 KiB
JavaScript
// ── Session middleware ─────────────────────────────────────────────────────
|
|
//
|
|
// Express middleware built on the session service. Three pieces:
|
|
//
|
|
// attachSession — best-effort: decorate the request with session info if a
|
|
// valid token is present, but never reject. For routes that
|
|
// behave differently for anon vs authed callers.
|
|
// requireAuth — the gate for protected routes. Preserves the exact behavior
|
|
// of the old isLoggedIn: re-validate the user against the DB on
|
|
// every request so a demoted/deleted user loses access
|
|
// immediately, and set req.user to the fresh DB row.
|
|
// requireRole — role gate factory, unchanged from the original.
|
|
|
|
const sessionService = require('./session.service')
|
|
const users = require('../model/users/users.model')
|
|
const log = require('../utils/logger')('session')
|
|
|
|
// True if this session was issued at or before the user's tokens_valid_after
|
|
// cutoff (i.e. revoked by a password change / log-out-everywhere). Both the JWT
|
|
// iat and the cutoff are second-granular, so the comparison is inclusive: a token
|
|
// minted in the same second as the bump must still be revoked (otherwise it would
|
|
// survive its full lifetime through that 1s alignment). The only cost is that a
|
|
// re-login within the same second as the change is rejected until the next second
|
|
// — a self-healing blip, and far preferable to leaving a stale token valid.
|
|
function isBeforeCutoff(session, tokensValidAfter) {
|
|
if (!tokensValidAfter || session.createdAt == null) return false
|
|
return session.createdAt <= new Date(tokensValidAfter).getTime()
|
|
}
|
|
|
|
// Best-effort: if the request carries a valid session token, attach the decoded
|
|
// session (no DB hit), its auth method, and request metadata. Never rejects —
|
|
// anonymous requests simply pass through with req.session undefined.
|
|
function attachSession(req, res, next) {
|
|
const session = sessionService.validateSession(req)
|
|
if (session) {
|
|
req.session = session
|
|
req.authMethod = session.authMethod
|
|
req.sessionMeta = sessionService.sessionMeta(req)
|
|
}
|
|
return next()
|
|
}
|
|
|
|
// Gate middleware for protected (admin) routes. Re-validates the token against
|
|
// the database on every request so a demoted or deleted user loses access
|
|
// immediately, instead of keeping their old role (or a working session) until
|
|
// the JWT expires. req.user carries the fresh DB row, not the token payload.
|
|
async function requireAuth(req, res, next) {
|
|
const session = sessionService.validateSession(req)
|
|
if (!session) return res.status(401).json({ message: 'Unauthorized' })
|
|
try {
|
|
const user = await users.getById(session.userId)
|
|
if (!user) return res.status(401).json({ message: 'Unauthorized' }) // deleted since token issued
|
|
|
|
// Status gate, enforced on every request (same immediacy as the cutoff
|
|
// below): a player disabled/banned by staff loses access on their very next
|
|
// request, not when their JWT eventually expires.
|
|
if (user.status && user.status !== 'active') {
|
|
return res.status(403).json({ message: 'Account disabled' })
|
|
}
|
|
|
|
// Revocation, enforced here (not in stateless token verification):
|
|
// 1. per-user cutoff — password change / "log out everywhere" bumps
|
|
// tokens_valid_after; any token issued before it is dead.
|
|
// 2. per-session denylist — a single logout adds this jti to revoked_sessions.
|
|
if (isBeforeCutoff(session, user.tokens_valid_after)) {
|
|
return res.status(401).json({ message: 'Unauthorized' })
|
|
}
|
|
if (await sessionService.isSessionRevoked(session.sessionId)) {
|
|
return res.status(401).json({ message: 'Unauthorized' })
|
|
}
|
|
|
|
req.user = user
|
|
req.session = session
|
|
req.authMethod = session.authMethod
|
|
return next()
|
|
} catch (err) {
|
|
log.error('requireAuth', err)
|
|
return res.status(500).json({ message: 'Internal Server Error' })
|
|
}
|
|
}
|
|
|
|
// 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.
|
|
function requireRole(...roles) {
|
|
return (req, res, next) => {
|
|
if (roles.includes(req.user?.role)) return next()
|
|
return res.status(403).json({ message: 'Forbidden' })
|
|
}
|
|
}
|
|
|
|
module.exports = {
|
|
attachSession,
|
|
optionalAuth,
|
|
requireAuth,
|
|
requireRole,
|
|
}
|