feat(teams): Teams as a platform primitive — MODULE_API 1.6.0 (Teams cutover 4/6) #161

Merged
whitlocktech merged 45 commits from edge into main 2026-08-19 08:57:13 +00:00
9 changed files with 1242 additions and 0 deletions
Showing only changes of commit e27c368234 - Show all commits

View File

@@ -41,6 +41,49 @@ async function activeGrants(teamId) {
) )
} }
/** How many active grants a team currently holds — the §2.5 per-Team cap reads this. */
async function activeGrantCount(teamId) {
const rows = await query(
'SELECT COUNT(*) AS n FROM team_forum_grants WHERE team_id = ? AND revoked_at IS NULL',
[teamId],
)
return Number(rows[0]?.n || 0)
}
/**
* Issue a grant.
*
* Writes nothing but this table — that is the non-contamination invariant, and it
* is a property of this function being the ONLY writer on the grant path rather
* than of anyone remembering it at the call site. The username snapshots are
* taken here so the ledger still reads after either account is deleted (§2.10).
*/
async function insertGrant({ teamId, userId, username, grantedBy, grantedUsername, reason }) {
const res = await query(
`INSERT INTO team_forum_grants (team_id, user_id, username, granted_by, granted_username, reason)
VALUES (?, ?, ?, ?, ?, ?)`,
[teamId, userId, username, grantedBy, grantedUsername, reason ?? null],
)
return res.insertId
}
/**
* Revoke the active grant, if there is one.
*
* An UPDATE of the existing row rather than a delete: the table is a ledger as
* well as the current state, and `revoked_at` is what moves a row out of the
* unique key (the generated `active_marker` goes NULL) while keeping the history.
*/
async function revokeGrant({ teamId, userId, revokedBy, revokedUsername, reason }) {
const res = await query(
`UPDATE team_forum_grants
SET revoked_at = NOW(), revoked_by = ?, revoked_username = ?, revoke_reason = ?
WHERE team_id = ? AND user_id = ? AND revoked_at IS NULL`,
[revokedBy, revokedUsername, reason ?? null, teamId, userId],
)
return res.affectedRows > 0
}
// ── team_leader_overrides (§2.5.1) ───────────────────────────────────────── // ── team_leader_overrides (§2.5.1) ─────────────────────────────────────────
const OVERRIDE_COLUMNS = 'team_id, member_key, effect, actor_user_id, actor_username, reason, created_at' const OVERRIDE_COLUMNS = 'team_id, member_key, effect, actor_user_id, actor_username, reason, created_at'
@@ -87,6 +130,9 @@ module.exports = {
activeGrant, activeGrant,
grantLedger, grantLedger,
activeGrants, activeGrants,
activeGrantCount,
insertGrant,
revokeGrant,
overridesForTeam, overridesForTeam,
overrideFor, overrideFor,
setOverride, setOverride,

View File

@@ -0,0 +1,236 @@
// SQL for the four forum tables (TEAMS.md §5.2, §5.2a).
//
// Kept apart from teamAccess.db.js for the same reason that file is kept apart
// from teams.db.js: forum CONTENT and forum ACCESS are different questions, and a
// query here that read `team_members` to decide who may see a thread would be the
// exact collapse §2.5 forbids. Nothing in this file resolves access; callers hand
// it a decision the resolver already made.
const { query } = require('../../utils/db')
const THREAD_COLUMNS = `
id, team_id, type, title, created_by, created_username, created_at,
last_post_at, post_count, pinned, locked, status`
const POST_COLUMNS = `
id, thread_id, author_user_id, author_username, body_html, created_at,
edited_at, edited_by, status`
// ── threads ────────────────────────────────────────────────────────────────
/**
* A Team's threads, newest activity first with pinned rows on top.
*
* `includeHidden` is the staff/leader view. Hidden is not deleted: a hidden
* thread stays in the ledger and comes back with `unhide`, which is why the
* status filter is a parameter rather than a WHERE clause everyone remembers.
*/
async function threadsByTeam(teamId, { includeHidden = false, limit = 50, offset = 0 } = {}) {
const statuses = includeHidden ? "('visible','hidden')" : "('visible')"
return query(
`SELECT ${THREAD_COLUMNS} FROM team_forum_threads
WHERE team_id = ? AND status IN ${statuses}
ORDER BY pinned DESC, COALESCE(last_post_at, created_at) DESC, id DESC
LIMIT ? OFFSET ?`,
[teamId, limit, offset],
)
}
async function threadById(id) {
const rows = await query(`SELECT ${THREAD_COLUMNS} FROM team_forum_threads WHERE id = ? LIMIT 1`, [id])
return rows[0] || null
}
async function insertThread({ teamId, type, title, createdBy, createdUsername }) {
const res = await query(
`INSERT INTO team_forum_threads (team_id, type, title, created_by, created_username, last_post_at, post_count)
VALUES (?, ?, ?, ?, ?, NOW(), 0)`,
[teamId, type, title, createdBy, createdUsername],
)
return res.insertId
}
/** Apply one moderation action's effect. The LEDGER row is written separately. */
async function setThreadFlags(id, { pinned, locked, status }) {
const sets = []
const args = []
if (pinned !== undefined) { sets.push('pinned = ?'); args.push(pinned ? 1 : 0) }
if (locked !== undefined) { sets.push('locked = ?'); args.push(locked ? 1 : 0) }
if (status !== undefined) { sets.push('status = ?'); args.push(status) }
if (!sets.length) return false
args.push(id)
const res = await query(`UPDATE team_forum_threads SET ${sets.join(', ')} WHERE id = ?`, args)
return res.affectedRows > 0
}
// ── posts ──────────────────────────────────────────────────────────────────
async function postsByThread(threadId, { includeHidden = false } = {}) {
const statuses = includeHidden ? "('visible','hidden')" : "('visible')"
return query(
`SELECT ${POST_COLUMNS} FROM team_forum_posts
WHERE thread_id = ? AND status IN ${statuses} ORDER BY created_at, id`,
[threadId],
)
}
async function postById(id) {
const rows = await query(`SELECT ${POST_COLUMNS} FROM team_forum_posts WHERE id = ? LIMIT 1`, [id])
return rows[0] || null
}
/**
* Append a post and move the thread's counters in the same breath.
*
* Two statements rather than a trigger: the counters are a denormalisation for
* the thread list, and a trigger would put half the write in the schema where
* nobody reading this file would find it.
*/
async function insertPost({ threadId, authorUserId, authorUsername, bodyHtml }) {
const res = await query(
`INSERT INTO team_forum_posts (thread_id, author_user_id, author_username, body_html)
VALUES (?, ?, ?, ?)`,
[threadId, authorUserId, authorUsername, bodyHtml],
)
await query(
'UPDATE team_forum_threads SET post_count = post_count + 1, last_post_at = NOW() WHERE id = ?',
[threadId],
)
return res.insertId
}
async function setPostStatus(id, status) {
const res = await query('UPDATE team_forum_posts SET status = ? WHERE id = ?', [status, id])
return res.affectedRows > 0
}
// ── the moderation ledger (append-only) ────────────────────────────────────
async function insertModeration({ teamId, targetType, targetId, action, actorUserId, actorUsername, actorRole, reason }) {
await query(
`INSERT INTO team_forum_moderation
(team_id, target_type, target_id, action, actor_user_id, actor_username, actor_role, reason)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
[teamId, targetType, targetId, action, actorUserId, actorUsername, actorRole, reason ?? null],
)
}
async function moderationForTeam(teamId, { limit = 100, offset = 0 } = {}) {
return query(
`SELECT id, team_id, target_type, target_id, action, actor_user_id, actor_username,
actor_role, reason, created_at
FROM team_forum_moderation WHERE team_id = ?
ORDER BY created_at DESC, id DESC LIMIT ? OFFSET ?`,
[teamId, limit, offset],
)
}
// ── uploads (§5.2a) ────────────────────────────────────────────────────────
const UPLOAD_COLUMNS = `
id, team_id, post_id, uploader_user_id, uploader_username, filename, mimetype,
byte_size, created_at, deleted_at, deleted_by`
async function insertUpload({ teamId, postId, uploaderUserId, uploaderUsername, filename, mimetype, byteSize }) {
const res = await query(
`INSERT INTO team_forum_uploads
(team_id, post_id, uploader_user_id, uploader_username, filename, mimetype, byte_size)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
[teamId, postId ?? null, uploaderUserId, uploaderUsername, filename, mimetype, byteSize],
)
return res.insertId
}
async function uploadById(id) {
const rows = await query(`SELECT ${UPLOAD_COLUMNS} FROM team_forum_uploads WHERE id = ? LIMIT 1`, [id])
return rows[0] || null
}
/** Bytes this account has uploaded in the trailing window — the §5.5.4 daily quota. */
async function bytesUploadedSince(userId, sinceHours) {
const rows = await query(
`SELECT COALESCE(SUM(byte_size), 0) AS bytes FROM team_forum_uploads
WHERE uploader_user_id = ? AND created_at > (NOW() - INTERVAL ? HOUR)`,
[userId, sinceHours],
)
return Number(rows[0]?.bytes || 0)
}
/** The admin attribution view: who uploaded what, when, how much, and where. */
async function listUploads({ limit = 100, offset = 0, includeDeleted = false } = {}) {
return query(
`SELECT u.id, u.team_id, u.post_id, u.uploader_user_id, u.uploader_username,
u.filename, u.mimetype, u.byte_size, u.created_at, u.deleted_at, u.deleted_by,
t.name AS team_name, t.slug AS team_slug
FROM team_forum_uploads u JOIN teams t ON t.id = u.team_id
${includeDeleted ? '' : 'WHERE u.deleted_at IS NULL'}
ORDER BY u.created_at DESC, u.id DESC LIMIT ? OFFSET ?`,
[limit, offset],
)
}
async function softDeleteUpload(id, deletedBy) {
const res = await query(
'UPDATE team_forum_uploads SET deleted_at = NOW(), deleted_by = ? WHERE id = ? AND deleted_at IS NULL',
[deletedBy, id],
)
return res.affectedRows > 0
}
/** Soft-delete every upload attached to a post — the lifecycle half of §5.5.4. */
async function softDeleteUploadsForPost(postId, deletedBy) {
await query(
'UPDATE team_forum_uploads SET deleted_at = NOW(), deleted_by = ? WHERE post_id = ? AND deleted_at IS NULL',
[deletedBy, postId],
)
}
/** Rows soft-deleted longer ago than the retention window — the sweep's worklist. */
async function sweepableUploads(retentionDays) {
return query(
`SELECT id, filename FROM team_forum_uploads
WHERE deleted_at IS NOT NULL AND deleted_at < (NOW() - INTERVAL ? DAY)`,
[retentionDays],
)
}
/** Never-referenced uploads older than the grace period — a composer opened and abandoned. */
async function orphanedUploads(graceHours) {
return query(
`SELECT id, filename FROM team_forum_uploads
WHERE post_id IS NULL AND deleted_at IS NULL AND created_at < (NOW() - INTERVAL ? HOUR)`,
[graceHours],
)
}
async function deleteUploadRows(ids) {
if (!ids.length) return 0
const res = await query(
`DELETE FROM team_forum_uploads WHERE id IN (${ids.map(() => '?').join(',')})`,
ids,
)
return res.affectedRows
}
module.exports = {
threadsByTeam,
threadById,
insertThread,
setThreadFlags,
postsByThread,
postById,
insertPost,
setPostStatus,
insertModeration,
moderationForTeam,
insertUpload,
uploadById,
bytesUploadedSince,
listUploads,
softDeleteUpload,
softDeleteUploadsForPost,
sweepableUploads,
orphanedUploads,
deleteUploadRows,
}

View File

@@ -0,0 +1,193 @@
// ── The forum, phase 4 ("5a": access + announcements) ──────────────────────
//
// TEAMS.md §5.1's split is BY LAYER, not by feature: 5a ships the whole access
// model and a single announcements stream per Team; 5b opens discussion threads,
// replies and editing. The schema for all of it landed together, so 5b enables
// paths here rather than migrating data — which is why `type` is a parameter
// below and not a constant, and why `locked` is honoured on a thread nothing can
// reply to yet.
//
// **Every function here takes an already-resolved access decision.** Nothing in
// this file reads `team_members` or `team_forum_grants`; the caller asks
// teamAccess.forumAccess() once and hands the answer down. That is §5.4's "never
// by checking membership directly, which is how paths 1 and 3 would drift back
// together", made structural.
//
// **The read path is where the image policy is applied**, once, in `renderPost`.
// Not in the controller and never in the client: the client is TOLD the mode so it
// can draw the right composer, and is never the thing that decides whether an
// image appears (§5.5.6).
const forumDb = require('./teamForum.db')
const forumSettings = require('./teamForumSettings.model')
const { cleanForumBody, renderForumBody } = require('../../utils/forumHtml')
// Announcements are leader-authored and replies are disabled; 5b's discussion
// threads are member-authored and take replies. Both types exist in the enum from
// day one — this is the list of what 5a will CREATE.
const CREATABLE_TYPES_5A = ['announcement']
const DELETED_AUTHOR = '[deleted account]'
/**
* Moderation actions, and what each one does to the row.
*
* A table rather than a switch because the ledger and the effect have to stay in
* step: every entry here writes one row of `team_forum_moderation` naming the
* authority that was exercised, and an action with an effect but no ledger entry
* would be a moderation nobody can audit.
*/
const THREAD_ACTIONS = {
pin: { pinned: true },
unpin: { pinned: false },
lock: { locked: true },
unlock: { locked: false },
hide: { status: 'hidden' },
unhide: { status: 'visible' },
delete: { status: 'deleted' },
restore: { status: 'visible' },
}
function publicThread(row) {
return {
id: row.id,
type: row.type,
title: row.title,
author: row.created_username || DELETED_AUTHOR,
authorDeleted: row.created_by == null,
createdAt: row.created_at,
lastPostAt: row.last_post_at,
postCount: row.post_count,
pinned: Boolean(row.pinned),
locked: Boolean(row.locked),
status: row.status,
}
}
/**
* One post, rendered for one image policy.
*
* `body` is what the reader gets and `mode` decides whether it carries images.
* The STORED html is never modified — flipping the policy changes this function's
* output and nothing on disk, which is the property §5.5.3 exists to give and the
* one acceptance criterion 3 measures.
*/
function renderPost(row, mode) {
return {
id: row.id,
author: row.author_username || DELETED_AUTHOR,
authorDeleted: row.author_user_id == null,
body: renderForumBody(row.body_html, mode),
createdAt: row.created_at,
editedAt: row.edited_at,
status: row.status,
}
}
/**
* The thread list for one viewer.
*
* `canModerate` widens what is returned, not just what is offered: a hidden
* thread is visible to the people who can unhide it and to nobody else, so the
* same call answers both audiences without a second endpoint that could disagree
* with this one.
*/
async function listThreads(teamId, { canModerate = false, limit = 50, offset = 0 } = {}) {
const rows = await forumDb.threadsByTeam(teamId, { includeHidden: canModerate, limit, offset })
return rows.map(publicThread)
}
/** One thread with its posts, rendered under the current image policy. */
async function getThread(teamId, threadId, { canModerate = false } = {}) {
const thread = await forumDb.threadById(threadId)
// The team check is here rather than in the SQL so a thread id from another
// Team reads as "not found" and not as "found, but not yours" — a forum is a
// private room and the existence of a thread in it is itself private.
if (!thread || thread.team_id !== teamId) return null
if (thread.status === 'deleted' && !canModerate) return null
if (thread.status === 'hidden' && !canModerate) return null
const mode = await forumSettings.imageMode()
const posts = await forumDb.postsByThread(threadId, { includeHidden: canModerate })
return { ...publicThread(thread), posts: posts.map((p) => renderPost(p, mode)) }
}
/**
* Post an announcement: a thread and its first post, in one call.
*
* An announcement is a degenerate thread rather than its own thing (§5.1) — which
* is why this writes the ordinary tables and 5b adds no migration. `locked` is
* left false: replies are refused because the TYPE takes none, not because the
* thread was closed, and conflating the two would make "unlock" look like it
* would open replies on an announcement.
*/
async function createThread({ team, actor, type, title, body }) {
if (!CREATABLE_TYPES_5A.includes(type)) {
return { ok: false, status: 400, error: 'Only announcements can be posted yet' }
}
const cleaned = cleanForumBody(body)
if (!cleaned || !cleaned.replace(/<[^>]*>/g, '').trim()) {
return { ok: false, status: 400, error: 'An announcement needs a body' }
}
const threadId = await forumDb.insertThread({
teamId: team.id,
type,
title,
createdBy: actor.id,
createdUsername: actor.username,
})
await forumDb.insertPost({
threadId,
authorUserId: actor.id,
authorUsername: actor.username,
bodyHtml: cleaned,
})
return { ok: true, threadId }
}
/**
* Apply a moderation action to a thread, and record WHICH authority did it.
*
* `actorRole` is 'leader' or 'staff' — the column that makes a leader's ordinary
* housekeeping distinguishable from a staff intervention after the fact (§5.3).
* The caller resolves it; this function records it and never infers it, because
* an actor who is both would otherwise be recorded as whichever the code checked
* first.
*/
async function moderateThread({ team, threadId, action, actor, actorRole, reason }) {
const effect = THREAD_ACTIONS[action]
if (!effect) return { ok: false, status: 400, error: 'Unknown moderation action' }
const thread = await forumDb.threadById(threadId)
if (!thread || thread.team_id !== team.id) return { ok: false, status: 404, error: 'Thread not found' }
await forumDb.setThreadFlags(threadId, effect)
await forumDb.insertModeration({
teamId: team.id,
targetType: 'thread',
targetId: threadId,
action,
actorUserId: actor.id,
actorUsername: actor.username,
actorRole,
reason,
})
return { ok: true, action, threadId }
}
/** The ledger for the admin Team page. Staff-only by its route, not by this function. */
async function moderationLedger(teamId, opts) {
return forumDb.moderationForTeam(teamId, opts)
}
module.exports = {
CREATABLE_TYPES_5A,
THREAD_ACTIONS,
listThreads,
getThread,
createThread,
moderateThread,
moderationLedger,
publicThread,
renderPost,
}

View File

@@ -0,0 +1,165 @@
// ── The grant/revoke flow (TEAMS.md §2.5 path 3) ───────────────────────────
//
// The RESOLVER lives in teamAccess.model.js and answers "may this account use the
// forum". This file is the WRITE half: who may hand that access out, to whom, and
// what stops a leader turning a Team forum into open hosting on the operator's
// site.
//
// **Two authorities, and they are not the same authority with different reach.**
//
// staff (admin | moderator) — any Team, no cap, may revoke anything
// leader (path 2, on THIS Team) — own Team, capped, may not revoke a staff grant
//
// The last clause is the one worth stating: a leader who could revoke a
// staff-issued grant could undo a moderation decision, which is the whole reason
// `granted_by` is retained rather than collapsed into a boolean.
//
// **Nothing here writes `team_members`, in either direction, ever.** A grant is
// not a membership: it may name any Runic Gateway account, including one with no
// linked game identity at all — that is the point of it, since letting an unlinked
// guildmate into the forum must not be a staff ticket. `teams.model.js` keeps such
// an account off the roster and out of every membership count, and path 4 keeps it
// off external platforms.
const accessDb = require('./teamAccess.db')
const teamsDb = require('./teams.db')
const access = require('./teamAccess.model')
const usersDb = require('../users/users.db')
const settingsDb = require('../settings/settings.db')
// The per-Team ceiling on ACTIVE leader-issued grants. A leader admitting
// unlimited arbitrary accounts to a private space on the operator's host is a
// quiet way to turn a Team forum into free hosting; the cap is what makes it a
// decision the operator made rather than one a leader made for them.
const CAP_KEY = 'teams_max_grants_per_team'
const DEFAULT_CAP = 50
const STAFF_ROLES = ['admin', 'moderator']
async function grantCap() {
const raw = await settingsDb.get(CAP_KEY)
const n = Number.parseInt(raw, 10)
return Number.isFinite(n) && n > 0 ? n : DEFAULT_CAP
}
const isStaff = (actor) => STAFF_ROLES.includes(actor?.role)
/**
* What may this actor do with grants on this Team?
*
* Resolved once and returned whole, so the controller asks a question rather than
* assembling the answer from three booleans — the shape that lets a leader check
* and a staff check drift apart.
*/
async function authorityFor(teamId, actor) {
if (isStaff(actor)) return { may: true, as: 'staff' }
const leads = await access.isLeaderByUser(teamId, actor?.id)
return { may: leads, as: leads ? 'leader' : null }
}
/**
* Issue a grant. Returns the model result shape the Teams controllers translate:
* `{ ok }` or `{ ok: false, status, error }`.
*
* `warning` on a staff grant past the cap is deliberate and is not an error:
* staff are exempt, and silently exceeding a ceiling the operator configured is
* worth saying out loud on the way past.
*/
async function grant({ team, actor, userId, username, reason }) {
const authority = await authorityFor(team.id, actor)
if (!authority.may) return { ok: false, status: 403, error: 'Not a leader of this Team' }
const target = userId
? await usersDb.findById(userId)
: await usersDb.findByUsername(username)
if (!target) return { ok: false, status: 404, error: 'No such account' }
const existing = await accessDb.activeGrant(team.id, target.id)
if (existing) return { ok: false, status: 409, error: 'That account already has an active grant' }
const cap = await grantCap()
const count = await accessDb.activeGrantCount(team.id)
let warning = null
if (count >= cap) {
if (authority.as === 'leader') {
return { ok: false, status: 409, error: `This Team has reached its limit of ${cap} forum guests` }
}
warning = `This Team is past the configured limit of ${cap} forum guests`
}
await accessDb.insertGrant({
teamId: team.id,
userId: target.id,
username: target.username,
grantedBy: actor.id,
grantedUsername: actor.username,
reason,
})
return { ok: true, as: authority.as, grantee: target.username, ...(warning ? { warning } : {}) }
}
/**
* Revoke a grant.
*
* The one asymmetry with `grant`: a leader may not revoke what staff issued.
* Checked against `granted_by`'s role AT REVOKE TIME rather than against a stored
* flag, so an account that has since lost its staff role stops protecting the
* grants it made — which is the behaviour an operator demoting someone expects.
*/
async function revoke({ team, actor, userId, reason }) {
const authority = await authorityFor(team.id, actor)
if (!authority.may) return { ok: false, status: 403, error: 'Not a leader of this Team' }
const existing = await accessDb.activeGrant(team.id, userId)
if (!existing) return { ok: false, status: 404, error: 'No active grant for that account' }
if (authority.as === 'leader' && existing.granted_by) {
const issuer = await usersDb.findById(existing.granted_by)
if (isStaff(issuer)) {
return { ok: false, status: 403, error: 'That access was granted by staff and only staff may revoke it' }
}
}
await accessDb.revokeGrant({
teamId: team.id,
userId,
revokedBy: actor.id,
revokedUsername: actor.username,
reason,
})
return { ok: true, as: authority.as, grantee: existing.username }
}
/**
* The Team's forum guests — active grants for accounts that are NOT members.
*
* The subtraction is the §3.2 "Forum guests" list: someone who is both a member
* and a grantee is a member, listed on the roster, and appears here not at all.
* Both facts stay true in the ledger; only the presentation picks one.
*/
async function forumGuests(teamId) {
const [grants, members] = await Promise.all([
accessDb.activeGrants(teamId),
teamsDb.membersByTeam(teamId, { includeDeparted: false }),
])
const memberUserIds = new Set(members.map((m) => m.user_id).filter((id) => id != null))
return grants
.filter((g) => g.user_id == null || !memberUserIds.has(g.user_id))
.map((g) => ({
userId: g.user_id,
username: g.username,
grantedBy: g.granted_username,
grantedAt: g.granted_at,
reason: g.reason,
}))
}
module.exports = {
CAP_KEY,
DEFAULT_CAP,
grantCap,
authorityFor,
grant,
revoke,
forumGuests,
}

View File

@@ -12,6 +12,10 @@ const access = require('../../../model/teams/teamAccess.model')
const teamSync = require('../../../model/teams/teamSync.model') const teamSync = require('../../../model/teams/teamSync.model')
const teamsDb = require('../../../model/teams/teams.db') const teamsDb = require('../../../model/teams/teams.db')
const activity = require('../../../model/activity/activity.model') 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') 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 ───────────────────────────── // ── Leadership overrides (§2.5.1) — NOT gated ─────────────────────────────
async function setLeaderOverride(req, res) { async function setLeaderOverride(req, res) {
@@ -195,6 +258,9 @@ async function decideRequest(req, res) {
} }
module.exports = { module.exports = {
forumModeration,
forumUploads,
forumSettingsState,
listTeams, listTeams,
getTeam, getTeam,
resync, resync,

View File

@@ -92,6 +92,37 @@ teamsRouter.post(
// ── :id paths ────────────────────────────────────────────────────────────── // ── :id paths ──────────────────────────────────────────────────────────────
// Both literal, and both under '/forum' rather than '/:id/forum', so they cannot
// be captured by the '/:id' lookup below — 'forum' is not an integer, but relying
// on the validator to reject it would mean the route table's meaning depended on
// a param check three lines further down.
teamsRouter.get(
'/forum/uploads',
// #swagger.tags = ['Admin · Teams']
// #swagger.summary = 'Upload attribution across every Team forum'
// #swagger.description = 'Who uploaded what, when and how much. This view is why an attribution table exists at all: the liability an operator accepts before enabling uploads is meaningless if "who uploaded this" cannot be answered afterwards. Deleted rows are excluded unless `deleted=1` — a soft-deleted upload still has bytes on disk until the sweep runs.'
// #swagger.parameters['limit'] = { in: 'query', required: false, schema: { type: 'integer' }, description: 'Page size (default 100).' }
// #swagger.parameters['offset'] = { in: 'query', required: false, schema: { type: 'integer' }, description: 'Rows to skip (default 0).' }
// #swagger.parameters['deleted'] = { in: 'query', required: false, schema: { type: 'string', enum: ['0','1'] }, description: 'Include soft-deleted uploads.' }
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.responses[200] = { description: 'Uploads with their attribution', content: { "application/json": { schema: { $ref: "#/components/schemas/TeamForumUploadList" } } } } */
query('limit').optional().isInt({ min: 1, max: 500 }).toInt(),
query('offset').optional().isInt({ min: 0 }).toInt(),
query('deleted').optional().isIn(['0', '1']),
validate,
ctrl.forumUploads,
)
teamsRouter.get(
'/forum/settings',
// #swagger.tags = ['Admin · Teams']
// #swagger.summary = 'The forum switch, the image policy, and the acknowledgements state'
// #swagger.description = 'The two settings themselves ride the ordinary admin settings endpoint and are published to every client; this route adds the one thing that is NOT public — whether the uploads acknowledgement has been given, by whom, and whether the notice has been reworded since. A stale acknowledgement does not disable uploads: it raises a banner and freezes every other forum setting until it is re-given.'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.responses[200] = { description: 'Forum settings state', content: { "application/json": { schema: { $ref: "#/components/schemas/TeamForumSettingsState" } } } } */
ctrl.forumSettingsState,
)
teamsRouter.get( teamsRouter.get(
'/:id', '/:id',
// #swagger.tags = ['Admin · Teams'] // #swagger.tags = ['Admin · Teams']
@@ -119,6 +150,20 @@ teamsRouter.get(
ctrl.grants, ctrl.grants,
) )
teamsRouter.get(
'/:id/forum/moderation',
// #swagger.tags = ['Admin · Teams']
// #swagger.summary = 'A Teams forum moderation ledger'
// #swagger.description = 'Append-only, and deliberately separate from the sites mod_actions/appeals pair (§5.3): that one is Discord-sanction-shaped and bot-owned, and routing a guild leader locking a thread through it would make ordinary housekeeping an appealable sanction. `actorRole` records which authority was exercised — a leaders action appears only here, a staffers appears here AND in activity_log. Answers whether or not the forum is switched on.'
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'The Team id.' }
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.responses[200] = { description: 'The ledger, newest first', content: { "application/json": { schema: { $ref: "#/components/schemas/TeamForumModerationLedger" } } } } */
/* #swagger.responses[404] = { description: 'No such Team', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
param('id').isInt({ min: 1 }).toInt(),
validate,
ctrl.forumModeration,
)
teamsRouter.post( teamsRouter.post(
'/:id/archive', '/:id/archive',
// #swagger.tags = ['Admin · Teams'] // #swagger.tags = ['Admin · Teams']

View File

@@ -27,6 +27,7 @@ const noindex = require('../../../middleware/noindex')
const accountRouter = require('./account.router') const accountRouter = require('./account.router')
const appealsRouter = require('./appeals.router') const appealsRouter = require('./appeals.router')
const teamsRouter = require('./teams.router') const teamsRouter = require('./teams.router')
const teamForumRouter = require('./teamForum.router')
const playerRouter = express.Router() const playerRouter = express.Router()
@@ -41,5 +42,9 @@ playerRouter.use(noindex, requireAuth)
playerRouter.use('/account', accountRouter) playerRouter.use('/account', accountRouter)
playerRouter.use('/appeals', appealsRouter) playerRouter.use('/appeals', appealsRouter)
playerRouter.use('/teams', teamsRouter) playerRouter.use('/teams', teamsRouter)
// Same prefix, second router. The forum and the leader-exercised grant flow are a
// different capability from "the caller's own Teams", and splitting them keeps
// each file about one thing; no path in the two collides.
playerRouter.use('/teams', teamForumRouter)
module.exports = playerRouter module.exports = playerRouter

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,
}

View File

@@ -0,0 +1,198 @@
// Player · Team forums (TEAMS.md §5.4) and the leader-exercised grant flow (§2.11).
//
// Mounted at /api/v1/player/teams by player/index.js — the SAME prefix as
// teams.router.js, which is why this file exists separately rather than being
// merged into it: that router is the caller's own Team reads, this one is the
// forum and the grants. Express walks both in mount order and no path collides
// ('/:slug/access' vs '/:slug/forum/*' and '/:slug/grants').
//
// Every forum route here 404s while `teams_forums_enabled` is off, and the upload
// routes 404 in any image mode but `uploads`. Both guards are in the controller
// rather than in middleware here, because both need the resolved Team and the
// caller's access to decide, and a guard that answers before those are known
// would have to answer 403 — which is the thing §5.5.1 says not to say.
const express = require('express')
const { body, param } = require('express-validator')
const ctrl = require('./teamForum.controller')
const validate = require('../../../middleware/validate')
const { makeLimiter } = require('../../../middleware/rateLimit')
const { upload } = require('../admin/imageUpload')
const forumRouter = express.Router()
// Writes are rate-limited, reads are not. The caps are per IP and generous enough
// that a Team having a busy afternoon never meets them; what they stop is a script.
const postLimiter = makeLimiter({
windowMs: 10 * 60 * 1000,
max: 20,
label: 'team-forum-post',
message: 'Too many forum posts. Please slow down.',
})
// Tighter than posting, and for a different reason: §2.5 caps how many active
// grants a Team may hold, and this caps how fast a leader may approach that cap.
const grantLimiter = makeLimiter({
windowMs: 10 * 60 * 1000,
max: 15,
label: 'team-forum-grant',
message: 'Too many grant changes. Please slow down.',
})
// Bytes, not requests: the per-account daily quota lives in the uploads model,
// and this is the per-IP flood guard in front of it.
const uploadLimiter = makeLimiter({
windowMs: 10 * 60 * 1000,
max: 30,
label: 'team-forum-upload',
message: 'Too many uploads. Please slow down.',
})
forumRouter.get(
'/:slug/forum/threads',
// #swagger.tags = ['Player · Teams']
// #swagger.summary = 'List a Team forums threads'
// #swagger.description = 'Reachable by a member (path 1) OR a granted account (path 3) — a forum guest with no linked game identity reads exactly as a member does. Answers 404 while `teams_forums_enabled` 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. Hidden threads are included for a leader or staff and for nobody else.'
// #swagger.parameters['slug'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'The Team slug.' }
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.responses[200] = { description: 'The thread list, with what this caller may do', content: { "application/json": { schema: { $ref: "#/components/schemas/TeamForumThreadList" } } } } */
/* #swagger.responses[404] = { description: 'Forum off, no such Team, or no access', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
ctrl.listThreads,
)
forumRouter.post(
'/:slug/forum/threads',
// #swagger.tags = ['Player · Teams']
// #swagger.summary = 'Post an announcement'
// #swagger.description = 'Phase 4 ships a single announcements stream per Team: leader-authored, replies disabled. An announcement is a degenerate thread rather than its own kind of object, so phase 5s discussion threads add no migration. The body is sanitised with the FORUMs own profile, in which `img` is never allowed — an author writes a URL and core decides at render time whether it becomes a picture.'
// #swagger.parameters['slug'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'The Team slug.' }
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { type: 'object', required: ['title','body'], properties: { type: { type: 'string', enum: ['announcement'] }, title: { type: 'string', maxLength: 200 }, body: { type: 'string' } } } } } } */
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.responses[200] = { description: 'Posted', content: { "application/json": { schema: { type: 'object', properties: { ok: { type: 'boolean' }, threadId: { type: 'integer' } } } } } } */
/* #swagger.responses[403] = { description: 'Not a leader of this Team', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
postLimiter,
param('slug').isString().trim().isLength({ min: 1, max: 191 }),
body('type').optional().isIn(['announcement']),
body('title').isString().trim().isLength({ min: 1, max: 200 }),
body('body').isString().isLength({ min: 1, max: 40000 }),
validate,
ctrl.createThread,
)
forumRouter.get(
'/:slug/forum/threads/:id',
// #swagger.tags = ['Player · Teams']
// #swagger.summary = 'Read one thread and its posts'
// #swagger.description = 'Post bodies are rendered under the CURRENT image policy: `disabled` serves the stored HTML unchanged, `remote` and `uploads` add a core-generated <img> beneath each link that names an image. The stored HTML is identical in all three — flipping the policy back to disabled un-renders every image on every existing post with no data migration.'
// #swagger.parameters['slug'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'The Team slug.' }
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'The thread id.' }
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.responses[200] = { description: 'The thread', content: { "application/json": { schema: { $ref: "#/components/schemas/TeamForumThread" } } } } */
/* #swagger.responses[404] = { description: 'Forum off, no such thread, or no access', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
param('id').isInt({ min: 1 }).toInt(),
validate,
ctrl.getThread,
)
forumRouter.post(
'/:slug/forum/threads/:id/moderate',
// #swagger.tags = ['Player · Teams']
// #swagger.summary = 'Pin, lock, hide or delete a thread'
// #swagger.description = 'Leader or staff. Every action writes the Teams own append-only moderation ledger recording WHICH authority was exercised; a staff-exercised one additionally writes activity_log, so the sites staff-accountability trail sees it while a leaders ordinary housekeeping stays out of it. Deliberately not routed through the sites mod_actions/appeals pair, which is Discord-sanction-shaped.'
// #swagger.parameters['slug'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'The Team slug.' }
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'The thread id.' }
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { type: 'object', required: ['action'], properties: { action: { type: 'string', enum: ['pin','unpin','lock','unlock','hide','unhide','delete','restore'] }, reason: { type: 'string', maxLength: 255 } } } } } } */
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.responses[200] = { description: 'Applied', content: { "application/json": { schema: { type: 'object', properties: { ok: { type: 'boolean' }, action: { type: 'string' }, threadId: { type: 'integer' } } } } } } */
/* #swagger.responses[403] = { description: 'Not a leader of this Team', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
param('id').isInt({ min: 1 }).toInt(),
body('action').isIn(['pin', 'unpin', 'lock', 'unlock', 'hide', 'unhide', 'delete', 'restore']),
body('reason').optional().isString().trim().isLength({ max: 255 }),
validate,
ctrl.moderateThread,
)
// ── grants ─────────────────────────────────────────────────────────────────
forumRouter.get(
'/:slug/grants',
// #swagger.tags = ['Player · Teams']
// #swagger.summary = 'The Teams forum guests, and the per-Team cap'
// #swagger.description = 'Leader or staff. Lists ACTIVE grants for accounts that are not members — someone who is both is a member, appears on the roster, and is absent here. Answers regardless of whether the forum is switched on: a toggle-off revokes no grant, so the access list stays manageable while there is temporarily nothing to grant access to.'
// #swagger.parameters['slug'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'The Team slug.' }
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.responses[200] = { description: 'Forum guests', content: { "application/json": { schema: { $ref: "#/components/schemas/TeamForumGuestList" } } } } */
/* #swagger.responses[403] = { description: 'Not a leader of this Team', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
ctrl.listGrants,
)
forumRouter.post(
'/:slug/grants',
// #swagger.tags = ['Player · Teams']
// #swagger.summary = 'Grant forum access to an account'
// #swagger.description = 'A grant may name ANY Runic Gateway account, including one with no linked game identity — that is the point of it, since letting an unlinked guildmate into the forum must not be a staff ticket. It never writes team_members: the grantee stays off the roster, out of every membership count, and ineligible for external-platform access. A leader is capped at `teams_max_grants_per_team` active grants (default 50) and rate-limited; staff are exempt and are warned on the way past.'
// #swagger.parameters['slug'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'The Team slug.' }
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { type: 'object', properties: { userId: { type: 'integer' }, username: { type: 'string' }, reason: { type: 'string', maxLength: 255 } } } } } } */
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.responses[200] = { description: 'Granted', content: { "application/json": { schema: { type: 'object', properties: { ok: { type: 'boolean' }, grantee: { type: 'string' }, warning: { type: 'string' } } } } } } */
/* #swagger.responses[409] = { description: 'Already granted, or the Team is at its cap', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
grantLimiter,
body('userId').optional().isInt({ min: 1 }).toInt(),
body('username').optional().isString().trim().isLength({ min: 1, max: 32 }),
body('reason').optional().isString().trim().isLength({ max: 255 }),
validate,
ctrl.createGrant,
)
forumRouter.delete(
'/:slug/grants/:userId',
// #swagger.tags = ['Player · Teams']
// #swagger.summary = 'Revoke forum access'
// #swagger.description = 'The grant row is updated rather than deleted — the table is the audit ledger as well as the current state. A leader may not revoke a STAFF-issued grant, which is what stops a leader undoing a moderation decision; the issuers role is checked at revoke time, so an account that has since lost its staff role stops protecting the grants it made.'
// #swagger.parameters['slug'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'The Team slug.' }
// #swagger.parameters['userId'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'The grantees account id.' }
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.responses[200] = { description: 'Revoked', content: { "application/json": { schema: { type: 'object', properties: { ok: { type: 'boolean' }, grantee: { type: 'string' } } } } } } */
/* #swagger.responses[403] = { description: 'Not a leader, or the grant was staff-issued', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
grantLimiter,
param('userId').isInt({ min: 1 }).toInt(),
body('reason').optional().isString().trim().isLength({ max: 255 }),
validate,
ctrl.revokeGrant,
)
// ── uploads ────────────────────────────────────────────────────────────────
forumRouter.post(
'/:slug/forum/uploads',
// #swagger.tags = ['Player · Teams']
// #swagger.summary = 'Upload an image to a Team forum'
// #swagger.description = 'Multipart. Answers 404 in any image mode but `uploads`. Beyond the admin upload paths 8 MB cap, mimetype allowlist and random filename, this one assumes a hostile uploader: the leading bytes are sniffed and a mismatch with the declared type is rejected (a clients Content-Type header is a claim, not a fact), a rolling per-account byte quota applies, and every accepted file gets an attribution row naming who uploaded it.'
// #swagger.parameters['slug'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'The Team slug.' }
/* #swagger.requestBody = { required: true, content: { "multipart/form-data": { schema: { type: 'object', properties: { image: { type: 'string', format: 'binary' } } } } } } */
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.responses[200] = { description: 'Stored', content: { "application/json": { schema: { type: 'object', properties: { ok: { type: 'boolean' }, id: { type: 'integer' }, url: { type: 'string' }, bytes: { type: 'integer' } } } } } } */
/* #swagger.responses[400] = { description: 'Not the image type it claims to be', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
/* #swagger.responses[429] = { description: 'Daily upload quota reached', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
uploadLimiter,
upload.single('image'),
ctrl.createUpload,
)
forumRouter.delete(
'/:slug/forum/uploads/:id',
// #swagger.tags = ['Player · Teams']
// #swagger.summary = 'Remove an uploaded image'
// #swagger.description = 'The uploader or staff. Soft: the row is marked and the bytes go with the nightly sweep after a retention window, so a mis-click is recoverable. Note that disabling uploads later stops new files being accepted and does not remove files already uploaded — that is what this route is for.'
// #swagger.parameters['slug'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'The Team slug.' }
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'The upload id.' }
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.responses[200] = { description: 'Removed', content: { "application/json": { schema: { type: 'object', properties: { ok: { type: 'boolean' } } } } } } */
/* #swagger.responses[403] = { description: 'Not your upload', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
param('id').isInt({ min: 1 }).toInt(),
validate,
ctrl.deleteUpload,
)
module.exports = forumRouter