feat(teams): Teams as a platform primitive — MODULE_API 1.6.0 (Teams cutover 4/6) #161
@@ -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) ─────────────────────────────────────────
|
||||
|
||||
const OVERRIDE_COLUMNS = 'team_id, member_key, effect, actor_user_id, actor_username, reason, created_at'
|
||||
@@ -87,6 +130,9 @@ module.exports = {
|
||||
activeGrant,
|
||||
grantLedger,
|
||||
activeGrants,
|
||||
activeGrantCount,
|
||||
insertGrant,
|
||||
revokeGrant,
|
||||
overridesForTeam,
|
||||
overrideFor,
|
||||
setOverride,
|
||||
|
||||
236
server/src/model/teams/teamForum.db.js
Normal file
236
server/src/model/teams/teamForum.db.js
Normal 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,
|
||||
}
|
||||
193
server/src/model/teams/teamForum.model.js
Normal file
193
server/src/model/teams/teamForum.model.js
Normal 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,
|
||||
}
|
||||
165
server/src/model/teams/teamGrants.model.js
Normal file
165
server/src/model/teams/teamGrants.model.js
Normal 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,
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -92,6 +92,37 @@ teamsRouter.post(
|
||||
|
||||
// ── :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 acknowledgement’s 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(
|
||||
'/:id',
|
||||
// #swagger.tags = ['Admin · Teams']
|
||||
@@ -119,6 +150,20 @@ teamsRouter.get(
|
||||
ctrl.grants,
|
||||
)
|
||||
|
||||
teamsRouter.get(
|
||||
'/:id/forum/moderation',
|
||||
// #swagger.tags = ['Admin · Teams']
|
||||
// #swagger.summary = 'A Team’s forum moderation ledger'
|
||||
// #swagger.description = 'Append-only, and deliberately separate from the site’s 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 leader’s action appears only here, a staffer’s 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(
|
||||
'/:id/archive',
|
||||
// #swagger.tags = ['Admin · Teams']
|
||||
|
||||
@@ -27,6 +27,7 @@ const noindex = require('../../../middleware/noindex')
|
||||
const accountRouter = require('./account.router')
|
||||
const appealsRouter = require('./appeals.router')
|
||||
const teamsRouter = require('./teams.router')
|
||||
const teamForumRouter = require('./teamForum.router')
|
||||
|
||||
const playerRouter = express.Router()
|
||||
|
||||
@@ -41,5 +42,9 @@ playerRouter.use(noindex, requireAuth)
|
||||
playerRouter.use('/account', accountRouter)
|
||||
playerRouter.use('/appeals', appealsRouter)
|
||||
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
|
||||
|
||||
288
server/src/router/v1/player/teamForum.controller.js
Normal file
288
server/src/router/v1/player/teamForum.controller.js
Normal 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,
|
||||
}
|
||||
198
server/src/router/v1/player/teamForum.router.js
Normal file
198
server/src/router/v1/player/teamForum.router.js
Normal 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 forum’s 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 5’s discussion threads add no migration. The body is sanitised with the FORUM’s 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 Team’s own append-only moderation ledger recording WHICH authority was exercised; a staff-exercised one additionally writes activity_log, so the site’s staff-accountability trail sees it while a leader’s ordinary housekeeping stays out of it. Deliberately not routed through the site’s 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 Team’s 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 issuer’s 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 grantee’s 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 path’s 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 client’s 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
|
||||
Reference in New Issue
Block a user