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:
@@ -12,6 +12,10 @@ const access = require('../../../model/teams/teamAccess.model')
|
||||
const teamSync = require('../../../model/teams/teamSync.model')
|
||||
const teamsDb = require('../../../model/teams/teams.db')
|
||||
const activity = require('../../../model/activity/activity.model')
|
||||
const forum = require('../../../model/teams/teamForum.model')
|
||||
const forumDb = require('../../../model/teams/teamForum.db')
|
||||
const forumUploadsModel = require('../../../model/teams/teamForumUploads.model')
|
||||
const forumSettings = require('../../../model/teams/teamForumSettings.model')
|
||||
|
||||
const log = require('../../../utils/logger')('teams')
|
||||
|
||||
@@ -85,6 +89,65 @@ async function grants(req, res) {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Forum: the ledger and the upload attribution view (§5.4) ──────────────
|
||||
|
||||
/**
|
||||
* A Team's forum moderation ledger.
|
||||
*
|
||||
* Served whether or not the forum is switched on, unlike every /player forum
|
||||
* route. The switch guards the forum as a FEATURE — what members can read and
|
||||
* write — and an operator who turned it off to deal with a problem is precisely
|
||||
* the operator who needs to see what was moderated (§5.5.1: no data is deleted).
|
||||
*/
|
||||
async function forumModeration(req, res) {
|
||||
try {
|
||||
const id = Number(req.params.id)
|
||||
const team = await teamsDb.findById(id)
|
||||
if (!team) return res.status(404).json({ message: 'Team not found' })
|
||||
return res.json({ entries: await forum.moderationLedger(id, { limit: 200 }) })
|
||||
} catch (err) {
|
||||
return fail(res, err, 'forum moderation')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Who uploaded what, when, and how much — across every Team.
|
||||
*
|
||||
* This view is the reason §5.5.4 added an attribution table at all: the
|
||||
* acknowledgement an operator gives before enabling uploads is meaningless if the
|
||||
* question it makes them responsible for cannot be answered afterwards.
|
||||
*/
|
||||
async function forumUploads(req, res) {
|
||||
try {
|
||||
return res.json({
|
||||
uploads: await forumDb.listUploads({
|
||||
limit: Number(req.query.limit) || 100,
|
||||
offset: Number(req.query.offset) || 0,
|
||||
includeDeleted: req.query.deleted === '1',
|
||||
}),
|
||||
quota: {
|
||||
dailyBytes: forumUploadsModel.DAILY_QUOTA_BYTES,
|
||||
retentionDays: forumUploadsModel.RETENTION_DAYS,
|
||||
},
|
||||
})
|
||||
} catch (err) {
|
||||
return fail(res, err, 'forum uploads')
|
||||
}
|
||||
}
|
||||
|
||||
/** The forum settings' own state — the acknowledgement, which is not a public key. */
|
||||
async function forumSettingsState(req, res) {
|
||||
try {
|
||||
return res.json({
|
||||
enabled: await forumSettings.forumsEnabled(),
|
||||
imageMode: await forumSettings.imageMode(),
|
||||
acknowledgement: await forumSettings.ackState(),
|
||||
})
|
||||
} catch (err) {
|
||||
return fail(res, err, 'forum settings')
|
||||
}
|
||||
}
|
||||
|
||||
// ── Leadership overrides (§2.5.1) — NOT gated ─────────────────────────────
|
||||
|
||||
async function setLeaderOverride(req, res) {
|
||||
@@ -195,6 +258,9 @@ async function decideRequest(req, res) {
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
forumModeration,
|
||||
forumUploads,
|
||||
forumSettingsState,
|
||||
listTeams,
|
||||
getTeam,
|
||||
resync,
|
||||
|
||||
Reference in New Issue
Block a user