feat(teams): the grant flow, announcements, and the routes behind both guards
Path 3's WRITE half. The resolver landed in phase 2; this is who may hand access
out, to whom, and what stops a leader turning a Team forum into open hosting on
the operator's site.
Two authorities, and not one authority with different reach. Staff may act on any
Team, uncapped, and may revoke anything. A leader may grant and revoke ordinary
access on their own Team, is capped at `teams_max_grants_per_team` (default 50),
is rate-limited, and may NOT revoke a staff-issued grant — which is what stops a
leader undoing a moderation decision. The issuer's role is checked at revoke time
rather than stored, so an account that has since lost its staff role stops
protecting the grants it made.
Nothing on this path writes team_members, in either direction. A grant may name any
account, including one with no linked game identity — that is the point of it — and
that account stays off the roster, out of every count, and ineligible for external
platforms.
Announcements are a degenerate thread rather than their own object, so phase 5 adds
no migration. Moderation records WHICH authority was exercised: a staff action also
writes activity_log, a leader's writes only the Team's own ledger. Merging the two
would make a guild leader locking a thread an appealable Discord sanction.
Every forum route answers 404 while the switch is off, and 404 — never 403 — to a
caller with no access: in a private room the contents and the existence are the
same secret. The grant routes deliberately answer even while the forum is OFF,
because a toggle-off revokes no grant and the access list has to stay manageable.
Under /player rather than /admin: a leader is a player, and the /admin tier gate is
requireRole('admin','editor','moderator') — putting a leader endpoint behind it
would mean widening that gate.
Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -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,
|
||||
}
|
||||
Reference in New Issue
Block a user