feat(teams): the grant flow, announcements, and the routes behind both guards

Path 3's WRITE half. The resolver landed in phase 2; this is who may hand access
out, to whom, and what stops a leader turning a Team forum into open hosting on
the operator's site.

Two authorities, and not one authority with different reach. Staff may act on any
Team, uncapped, and may revoke anything. A leader may grant and revoke ordinary
access on their own Team, is capped at `teams_max_grants_per_team` (default 50),
is rate-limited, and may NOT revoke a staff-issued grant — which is what stops a
leader undoing a moderation decision. The issuer's role is checked at revoke time
rather than stored, so an account that has since lost its staff role stops
protecting the grants it made.

Nothing on this path writes team_members, in either direction. A grant may name any
account, including one with no linked game identity — that is the point of it — and
that account stays off the roster, out of every count, and ineligible for external
platforms.

Announcements are a degenerate thread rather than their own object, so phase 5 adds
no migration. Moderation records WHICH authority was exercised: a staff action also
writes activity_log, a leader's writes only the Team's own ledger. Merging the two
would make a guild leader locking a thread an appealable Discord sanction.

Every forum route answers 404 while the switch is off, and 404 — never 403 — to a
caller with no access: in a private room the contents and the existence are the
same secret. The grant routes deliberately answer even while the forum is OFF,
because a toggle-off revokes no grant and the access list has to stay manageable.

Under /player rather than /admin: a leader is a player, and the /admin tier gate is
requireRole('admin','editor','moderator') — putting a leader endpoint behind it
would mean widening that gate.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-08-18 07:23:47 -05:00
parent fb70013adf
commit e27c368234
9 changed files with 1242 additions and 0 deletions

View File

@@ -0,0 +1,288 @@
// Player · Team forums — the participant surface (TEAMS.md §5.4).
//
// Under `/player` rather than `/admin` for the reason §2.11 gives: a forum
// participant may be a plain player, a LEADER is a player, and the `/admin` tier
// gate is `requireRole('admin','editor','moderator')` — putting a leader endpoint
// behind it would mean widening that gate. The leader check is a per-handler
// question on top of the tier's `requireAuth`.
//
// **Two guards run before anything else in this file, in this order:**
//
// 1. `teams_forums_enabled` — off means every route here answers 404, not 403.
// A 403 says "this exists and you may not have it", which advertises a
// feature the operator deliberately turned off; 404 says "not a thing on
// this site", which is the true statement (§5.5.1).
// 2. the §2.5 access resolver — and never a membership check. Both a member and
// a granted non-member reach the forum, and asking `team_members` directly
// here is precisely how paths 1 and 3 drift back together.
//
// Both live in `resolveForum` below so a handler cannot forget either.
const teamsDb = require('../../../model/teams/teams.db')
const access = require('../../../model/teams/teamAccess.model')
const grants = require('../../../model/teams/teamGrants.model')
const forum = require('../../../model/teams/teamForum.model')
const forumSettings = require('../../../model/teams/teamForumSettings.model')
const uploads = require('../../../model/teams/teamForumUploads.model')
const activity = require('../../../model/activity/activity.model')
const log = require('../../../utils/logger')('teams')
const STAFF_ROLES = ['admin', 'moderator']
const isStaff = (user) => STAFF_ROLES.includes(user?.role)
const fail = (res, err, what) => {
log.error(`player team forum: ${what} failed`, { message: err.message })
return res.status(500).json({ message: 'Internal Server Error' })
}
const send = (res, result, body = { ok: true }) =>
(result.ok ? res.json({ ...body, ...result }) : res.status(result.status || 400).json({ message: result.error }))
/**
* The two guards, plus the Team, plus what this caller may do in it.
*
* Returns null when the caller should see a 404 — which covers three different
* situations on purpose: the forum is switched off, the Team does not exist, and
* the caller has no access to it. A private room's contents and its existence are
* the same secret.
*/
async function resolveForum(req) {
if (!(await forumSettings.forumsEnabled())) return null
const team = await teamsDb.findBySlug(req.params.slug)
if (!team) return null
const resolved = await access.forumAccess(team.id, req.user.id)
const staff = isStaff(req.user)
if (!resolved.allowed && !staff) return null
return {
team,
access: resolved,
// Staff moderate anywhere; a leader moderates their own Team. `actorRole`
// records WHICH of the two was exercised, and leadership wins when both are
// true: a leader who is also a moderator acting on their own Team is doing
// ordinary housekeeping, and logging it as a staff intervention would put a
// guild's day-to-day tidying into the site's staff-accountability trail.
canModerate: resolved.isLeader || staff,
actorRole: resolved.isLeader ? 'leader' : 'staff',
}
}
// ── threads ────────────────────────────────────────────────────────────────
async function listThreads(req, res) {
try {
const ctx = await resolveForum(req)
if (!ctx) return res.status(404).json({ message: 'Not found' })
return res.json({
threads: await forum.listThreads(ctx.team.id, { canModerate: ctx.canModerate }),
canPost: ctx.canModerate,
canModerate: ctx.canModerate,
imageMode: await forumSettings.imageMode(),
})
} catch (err) {
return fail(res, err, 'list threads')
}
}
async function getThread(req, res) {
try {
const ctx = await resolveForum(req)
if (!ctx) return res.status(404).json({ message: 'Not found' })
const thread = await forum.getThread(ctx.team.id, Number(req.params.id), { canModerate: ctx.canModerate })
if (!thread) return res.status(404).json({ message: 'Not found' })
return res.json({ ...thread, canModerate: ctx.canModerate })
} catch (err) {
return fail(res, err, 'get thread')
}
}
/**
* Post an announcement. 5a: leaders (and staff) only, replies disabled.
*
* The `canModerate` gate is doing double duty here and that is deliberate for one
* phase only: in 5a the only creatable type is an announcement, whose author must
* be a leader. 5b adds `type: 'discussion'`, which any member may create — at
* which point the check splits by type rather than being widened.
*/
async function createThread(req, res) {
try {
const ctx = await resolveForum(req)
if (!ctx) return res.status(404).json({ message: 'Not found' })
if (!ctx.canModerate) return res.status(403).json({ message: 'Only Team leaders may post announcements' })
const result = await forum.createThread({
team: ctx.team,
actor: req.user,
type: req.body.type || 'announcement',
title: req.body.title,
body: req.body.body,
})
return send(res, result)
} catch (err) {
return fail(res, err, 'create thread')
}
}
/**
* Pin / lock / hide / delete a thread, and its opposites.
*
* A staff-exercised action ALSO writes `activity_log`; a leader-exercised one
* writes only the forum ledger (§5.3). That asymmetry is the whole reason the two
* ledgers are cross-referenced rather than merged: routing a guild leader locking
* a thread into the site's sanction pipeline would make ordinary housekeeping an
* appealable staff action.
*/
async function moderateThread(req, res) {
try {
const ctx = await resolveForum(req)
if (!ctx) return res.status(404).json({ message: 'Not found' })
if (!ctx.canModerate) return res.status(403).json({ message: 'Not a leader of this Team' })
const result = await forum.moderateThread({
team: ctx.team,
threadId: Number(req.params.id),
action: req.body.action,
actor: req.user,
actorRole: ctx.actorRole,
reason: req.body.reason,
})
if (result.ok && ctx.actorRole === 'staff') {
await activity.log({
req,
action: 'team.forum.moderate',
detail: `${req.user.username} (#${req.user.id}) ${req.body.action} thread #${req.params.id} `
+ `on team "${ctx.team.name}" (#${ctx.team.id})`
+ `${req.body.reason ? `: "${req.body.reason}"` : ''}`,
})
}
return send(res, result)
} catch (err) {
return fail(res, err, 'moderate thread')
}
}
// ── grants (§2.5 path 3, leader-exercised) ─────────────────────────────────
/**
* The grant surface is reachable whether or not the FORUM is on.
*
* Not an oversight: §5.5.1 says a toggle-off revokes no grant and that the rows
* stay authoritative, so a leader must still be able to see and manage them —
* they simply have nothing to grant access to for the moment. What the switch
* guards is the forum's CONTENT, not its access list.
*/
async function listGrants(req, res) {
try {
const team = await teamsDb.findBySlug(req.params.slug)
if (!team) return res.status(404).json({ message: 'Team not found' })
const authority = await grants.authorityFor(team.id, req.user)
if (!authority.may) return res.status(403).json({ message: 'Not a leader of this Team' })
return res.json({
guests: await grants.forumGuests(team.id),
cap: await grants.grantCap(),
as: authority.as,
})
} catch (err) {
return fail(res, err, 'list grants')
}
}
async function createGrant(req, res) {
try {
const team = await teamsDb.findBySlug(req.params.slug)
if (!team) return res.status(404).json({ message: 'Team not found' })
const result = await grants.grant({
team,
actor: req.user,
userId: req.body.userId,
username: req.body.username,
reason: req.body.reason,
})
if (result.ok && result.as === 'staff') {
await activity.log({
req,
action: 'team.forum.grant',
detail: `${req.user.username} (#${req.user.id}) granted forum access to ${result.grantee} `
+ `on team "${team.name}" (#${team.id})`,
})
}
return send(res, result)
} catch (err) {
return fail(res, err, 'create grant')
}
}
async function revokeGrant(req, res) {
try {
const team = await teamsDb.findBySlug(req.params.slug)
if (!team) return res.status(404).json({ message: 'Team not found' })
const result = await grants.revoke({
team,
actor: req.user,
userId: Number(req.params.userId),
reason: req.body.reason,
})
if (result.ok && result.as === 'staff') {
await activity.log({
req,
action: 'team.forum.revoke',
detail: `${req.user.username} (#${req.user.id}) revoked forum access from ${result.grantee} `
+ `on team "${team.name}" (#${team.id})`,
})
}
return send(res, result)
} catch (err) {
return fail(res, err, 'revoke grant')
}
}
// ── uploads (§5.5.4) ───────────────────────────────────────────────────────
/**
* The same 404 guard, applied at a second level: these routes answer 404 in any
* image mode but `uploads`, for the same reason the forum's do when the switch is
* off. An upload control the client offers and the server refuses is worse than
* no control, which is why the mode is published (§5.5.6) — but the SERVER is
* still what enforces it.
*/
async function createUpload(req, res) {
try {
if (!(await forumSettings.uploadsEnabled())) return res.status(404).json({ message: 'Not found' })
const ctx = await resolveForum(req)
if (!ctx) return res.status(404).json({ message: 'Not found' })
if (!req.file) return res.status(400).json({ message: 'No file uploaded' })
return send(res, await uploads.accept({ team: ctx.team, actor: req.user, file: req.file }))
} catch (err) {
return fail(res, err, 'upload')
}
}
async function deleteUpload(req, res) {
try {
if (!(await forumSettings.uploadsEnabled())) return res.status(404).json({ message: 'Not found' })
const ctx = await resolveForum(req)
if (!ctx) return res.status(404).json({ message: 'Not found' })
return send(res, await uploads.remove({
id: Number(req.params.id),
actor: req.user,
isStaff: isStaff(req.user),
}))
} catch (err) {
return fail(res, err, 'delete upload')
}
}
module.exports = {
listThreads,
getThread,
createThread,
moderateThread,
listGrants,
createGrant,
revokeGrant,
createUpload,
deleteUpload,
}