diff --git a/client/src/api/client.js b/client/src/api/client.js
index d35a67e..447803d 100644
--- a/client/src/api/client.js
+++ b/client/src/api/client.js
@@ -152,6 +152,26 @@ export const api = {
if (opts.offset != null) qs.set('offset', String(opts.offset))
return req(`/public/teams/${encodeURIComponent(slug)}/activity${withQs(qs.toString())}`)
},
+ // The Team FORUM, under /player because a participant may be a plain player and
+ // a leader is a player (TEAMS.md §2.11). Core's, for the same reason the feed is
+ // core's: only core resolves whether this viewer is inside the Team, and the
+ // member/guest split is a security boundary. The module renders the PLACE.
+ teamForumThreads: (slug) => req(`/player/teams/${encodeURIComponent(slug)}/forum/threads`),
+ teamForumThread: (slug, id) => req(`/player/teams/${encodeURIComponent(slug)}/forum/threads/${id}`),
+ teamForumPost: (slug, body) =>
+ req(`/player/teams/${encodeURIComponent(slug)}/forum/threads`, { method: 'POST', body }),
+ teamForumModerate: (slug, id, body) =>
+ req(`/player/teams/${encodeURIComponent(slug)}/forum/threads/${id}/moderate`, { method: 'POST', body }),
+ teamForumUpload: (slug, file) => {
+ const fd = new FormData()
+ fd.append('image', file)
+ return req(`/player/teams/${encodeURIComponent(slug)}/forum/uploads`, { method: 'POST', body: fd, raw: true })
+ },
+ teamGrantList: (slug) => req(`/player/teams/${encodeURIComponent(slug)}/grants`),
+ teamGrantAdd: (slug, body) =>
+ req(`/player/teams/${encodeURIComponent(slug)}/grants`, { method: 'POST', body }),
+ teamGrantRevoke: (slug, userId) =>
+ req(`/player/teams/${encodeURIComponent(slug)}/grants/${userId}`, { method: 'DELETE' }),
wikiTags: () => req('/public/wiki/tags'),
wikiPage: (slug) => req(`/public/wiki/${slug}`),
// CMS pages (block-based). Published-only for the public; a draft-preview link
@@ -284,6 +304,13 @@ export const api = {
req(`/admin/teams/${id}/leader-override`, { method: 'POST', body }),
clearTeamLeaderOverride: (id, memberKey) =>
req(`/admin/teams/${id}/leader-override/${encodeURIComponent(memberKey)}`, { method: 'DELETE' }),
+ teamForumSettings: () => req('/admin/teams/forum/settings'),
+ teamForumUploads: (opts = {}) => {
+ const qs = new URLSearchParams()
+ if (opts.deleted) qs.set('deleted', '1')
+ return req(`/admin/teams/forum/uploads${withQs(qs.toString())}`)
+ },
+ teamForumModeration: (id) => req(`/admin/teams/${id}/forum/moderation`),
teamReviewQueue: () => req('/admin/teams/review'),
teamRequests: (status) => req(`/admin/teams/requests${status ? `?status=${status}` : ''}`),
decideTeamRequest: (id, status, note) =>
diff --git a/client/src/main.jsx b/client/src/main.jsx
index 258fbbd..0d26693 100644
--- a/client/src/main.jsx
+++ b/client/src/main.jsx
@@ -5,6 +5,7 @@ import App from './App.jsx'
import { publishSharedDependencies } from './modules/shared.js'
import { declareSlot, applyCoreFills, fillModuleSlot } from './modules/registry.js'
import TeamActivityFeed from './modules/TeamActivityFeed.jsx'
+import TeamForumPanel from './modules/TeamForumPanel.jsx'
import './styles/theme.css'
// Publish window.__rg BEFORE rendering and before any module chunk evaluates.
@@ -75,6 +76,14 @@ declareSlot('player.invite.accepted')
// unfilled slot rendering nothing.
fillModuleSlot('uo.guild.detail', TeamActivityFeed)
+// The forum is core's for the same reason and goes in a SECOND place the module
+// declares, rather than joining the feed in the first: a slot takes one component
+// (first fill wins), and stacking two unrelated panels into one fill would make
+// the module unable to place them separately on its own page. It also keeps the
+// two independent — a deployment with the forum switched off renders the feed
+// exactly as before.
+fillModuleSlot('uo.guild.forum', TeamForumPanel)
+
// Render on DOMContentLoaded rather than immediately, and that is the one line
// of core's boot the module system changes.
//
diff --git a/client/src/modules/TeamForumPanel.jsx b/client/src/modules/TeamForumPanel.jsx
new file mode 100644
index 0000000..d961654
--- /dev/null
+++ b/client/src/modules/TeamForumPanel.jsx
@@ -0,0 +1,382 @@
+import { useCallback, useEffect, useState } from 'react'
+import { useSearchParams } from 'react-router-dom'
+import { api } from '../api/client.js'
+import { useAuth } from '../contexts/AuthContext.jsx'
+import { useSite } from '../contexts/SiteContext.jsx'
+
+// Core's Team forum, rendered into a second slot a MODULE declares
+// (TEAMS.md Part 5, and the phase 3 amendment to §3.4).
+//
+// **Why the forum is core's content on a module's page.** Everything that decides
+// who may read a thread is core's — the §2.5 resolver, the grants ledger, the
+// member/guest distinction — and none of it is a module's to reimplement. But
+// core does not own the word for a Team, so it publishes no Team page: the module
+// that says "guild" owns the page and declares a place on it, and core fills the
+// place. Same direction as the activity feed, same reason.
+//
+// **It is a whole forum inside one slot, and navigates by SEARCH PARAM.** A
+// thread needs to be linkable, and core cannot mount a route for it — the route
+// belongs to the module's page. `?thread=12` gives a shareable URL that works
+// under whatever path the module chose, with no route of core's anywhere in it,
+// and the browser's back button behaves. That is the whole reason this component
+// holds a list view and a detail view rather than being two components.
+//
+// **The image mode is published so this can draw the right composer — never to
+// decide what renders.** Post bodies arrive already rendered by the server under
+// the current policy (§5.5.3); the mode is read here only to show or hide an
+// upload control that would otherwise 404. If the two ever disagree, the server
+// is right.
+//
+// Like the feed, everything here degrades to rendering nothing. A 404 from the
+// thread list is the ordinary case — the forum is switched off, or this viewer
+// has no access — and putting an error box on a page core does not own would be
+// core reporting its own absence as a defect on someone else's surface.
+
+export default function TeamForumPanel({ externalId, moduleId }) {
+ const { user } = useAuth()
+ const { settings } = useSite()
+ const [params, setParams] = useSearchParams()
+ const [team, setTeam] = useState(null)
+ const [state, setState] = useState({ loading: true, forum: null })
+ const [thread, setThread] = useState(null)
+ const [composing, setComposing] = useState(false)
+
+ const openThreadId = params.get('thread')
+ const imageMode = settings?.teams_forum_images || 'disabled'
+ const forumsEnabled = String(settings?.teams_forums_enabled ?? '0') === '1'
+
+ const loadThreads = useCallback(async (slug) => {
+ try {
+ setState({ loading: false, forum: await api.teamForumThreads(slug) })
+ } catch {
+ setState({ loading: false, forum: null })
+ }
+ }, [])
+
+ useEffect(() => {
+ let active = true
+ // An anonymous visitor has no forum by definition — every route is behind
+ // requireAuth — so skip the two calls rather than provoking a 401 per page.
+ if (!externalId || !moduleId || !user || !forumsEnabled) {
+ setState({ loading: false, forum: null })
+ return undefined
+ }
+ // The module names the Team its own way; core resolves that to a slug. Same
+ // two-call shape as the activity feed, and for the same reason: a module
+ // never has to hold core's identifiers.
+ api.teamByExternalId(moduleId, externalId)
+ .then(async (found) => {
+ if (!active) return
+ setTeam(found)
+ await loadThreads(found.slug)
+ })
+ .catch(() => { if (active) setState({ loading: false, forum: null }) })
+ return () => { active = false }
+ }, [externalId, moduleId, user, forumsEnabled, loadThreads])
+
+ useEffect(() => {
+ let active = true
+ if (!team || !openThreadId) {
+ setThread(null)
+ return undefined
+ }
+ api.teamForumThread(team.slug, openThreadId)
+ .then((t) => { if (active) setThread(t) })
+ .catch(() => { if (active) setThread(null) })
+ return () => { active = false }
+ }, [team, openThreadId])
+
+ const openThread = (id) => {
+ const next = new URLSearchParams(params)
+ if (id == null) next.delete('thread')
+ else next.set('thread', String(id))
+ setParams(next)
+ }
+
+ const { loading, forum } = state
+ if (loading || !forum) return null
+
+ if (openThreadId && thread) {
+ return (
+
+ Nothing has been announced here yet.
+
+ Guests read and post in this forum without being members of the Team. They do not appear on the
+ roster and are not counted as members.
+ {data?.cap ? ` Up to ${data.cap} at a time.` : ''}
+ {error}
+ {thread.author}
+ {thread.authorDeleted && ' (account removed)'}
+
+ The image-upload notice has changed since it was accepted
+ {state.acknowledgement.acknowledgedBy ? ` by ${state.acknowledgement.acknowledgedBy}` : ''}.
+ Uploads keep working, but no forum setting can be saved until it is acknowledged again.
+ {line} {line} {REMOTE_ADVISORY}
+ ⚠ Image uploads are currently disabled.
+
+ Enabling uploads will allow users to store files on your server.
+ {DIALOG_TAIL} Banner: https://example.com/banner.png http://x.test/a.png
+ Announcements
+
+ {forum.canPost && !composing && (
+
+ )}
+
+ {forum.threads.map((t) => (
+
+ Forum guests
+
+
+ {(data?.guests || []).map((g) => (
+
+
+
+ {error &&
+ {thread.title}
+
+ in it was emitted by core's own
+ renderer with a fixed attribute set. A client-side sanitiser would have
+ to strip exactly the tag core just decided to add.
+ */}
+ {/* eslint-disable-next-line react/no-danger */}
+
+
tag. The author never
+ // writes markup here — core decides at render time whether a URL becomes a
+ // picture, which is what makes the operator's image policy enforceable
+ // rather than decorative.
+ setBody((current) => `${current}${current ? '\n\n' : ''}${url}`)
+ } catch (err) {
+ setError(err.message || 'Could not upload that')
+ }
+ }
+
+ return (
+
+ )
+}
diff --git a/client/src/routes/admin/views/SettingsAdmin.jsx b/client/src/routes/admin/views/SettingsAdmin.jsx
index 711d2b3..298a254 100644
--- a/client/src/routes/admin/views/SettingsAdmin.jsx
+++ b/client/src/routes/admin/views/SettingsAdmin.jsx
@@ -3,6 +3,7 @@ import { Loading, ErrorState } from '../../../components/PageState.jsx'
import { api } from '../../../api/client.js'
import { useSite } from '../../../contexts/SiteContext.jsx'
import EmailDelivery from './EmailDelivery.jsx'
+import TeamForumSettings from './TeamForumSettings.jsx'
// Lazy-loaded so the heavy rich-text editor stays code-split (matches PostEditor).
const RichTextEditor = lazy(() => import('../../../components/RichTextEditor.jsx'))
@@ -143,6 +144,8 @@ export default function SettingsAdmin() {
+
Team forums
+
+ {stale && (
+
+ {HELP_BULLETS.map((b) =>
+ {HELP_TAIL.map((line) => ` from any host, which would make `teams_forum_images` unenforceable:
+-- every post could hotlink in every mode and the setting would be decoration.
+-- No stored body ever contains an `
`; core's renderer emits those at read
+-- time from the URLs the author wrote (§5.5.3), which is why flipping the policy
+-- back to `disabled` un-renders every image on every existing post with no
+-- migration at all.
+CREATE TABLE IF NOT EXISTS team_forum_posts (
+ id BIGINT AUTO_INCREMENT PRIMARY KEY,
+ thread_id INT NOT NULL,
+ author_user_id INT NULL,
+ author_username VARCHAR(32) NULL, -- snapshot; renders as "[deleted account]" when both are gone
+ body_html MEDIUMTEXT NOT NULL, -- sanitised on write via utils/forumHtml.js
+ created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ edited_at DATETIME NULL,
+ edited_by INT NULL,
+ status ENUM('visible','hidden','deleted') NOT NULL DEFAULT 'visible',
+ CONSTRAINT fk_tfp_thread FOREIGN KEY (thread_id) REFERENCES team_forum_threads(id) ON DELETE CASCADE,
+ CONSTRAINT fk_tfp_user FOREIGN KEY (author_user_id) REFERENCES users(id) ON DELETE SET NULL,
+ CONSTRAINT fk_tfp_editor FOREIGN KEY (edited_by) REFERENCES users(id) ON DELETE SET NULL,
+ INDEX idx_tfp_thread (thread_id, status, created_at)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
+-- Append-only. Never updated, never deleted.
+--
+-- Deliberately NOT merged into the site's mod_actions/appeals pair (§5.3), which
+-- is Discord-sanction-shaped and bot-owned: routing a guild leader locking a
+-- thread through it would make ordinary housekeeping an appealable sanction with
+-- a reversal path into the bot. The two are cross-referenced instead — every
+-- STAFF-exercised action here additionally writes an activity_log row, so the
+-- site's staff-accountability trail sees it; a LEADER-exercised one writes only
+-- this ledger. `actor_role` records WHICH authority was exercised, which is the
+-- column that makes that distinction auditable after the fact.
+CREATE TABLE IF NOT EXISTS team_forum_moderation (
+ id BIGINT AUTO_INCREMENT PRIMARY KEY,
+ team_id INT NOT NULL,
+ target_type ENUM('thread','post') NOT NULL,
+ target_id BIGINT NOT NULL,
+ action ENUM('pin','unpin','lock','unlock','hide','unhide','delete','restore') NOT NULL,
+ actor_user_id INT NULL,
+ actor_username VARCHAR(32) NULL, -- snapshot (§2.10)
+ actor_role ENUM('leader','staff') NOT NULL,
+ reason VARCHAR(255) NULL,
+ created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ CONSTRAINT fk_tfm_team FOREIGN KEY (team_id) REFERENCES teams(id) ON DELETE CASCADE,
+ CONSTRAINT fk_tfm_actor FOREIGN KEY (actor_user_id) REFERENCES users(id) ON DELETE SET NULL,
+ INDEX idx_tfm_target (target_type, target_id),
+ INDEX idx_tfm_team (team_id, created_at)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
+-- Upload attribution (§5.2a, §5.5.4). Not bookkeeping: the acknowledgement an
+-- operator gives before enabling uploads is meaningless if "who uploaded this"
+-- cannot be answered afterwards, and the deletion sweep needs a row to sweep.
+--
+-- `post_id` is NULL between the upload and the post that embeds it — the composer
+-- uploads first and references the URL in the body — and that is exactly the state
+-- the orphan sweep looks for. `deleted_at` is a soft delete: the file survives a
+-- retention window so a mis-click is recoverable, then the nightly sweep removes
+-- the bytes.
+CREATE TABLE IF NOT EXISTS team_forum_uploads (
+ id BIGINT AUTO_INCREMENT PRIMARY KEY,
+ team_id INT NOT NULL,
+ post_id BIGINT NULL,
+ uploader_user_id INT NULL,
+ uploader_username VARCHAR(32) NULL, -- snapshot: attribution must survive the account
+ filename VARCHAR(255) NOT NULL, -- the STORED name, never originalname
+ mimetype VARCHAR(64) NOT NULL, -- the SNIFFED type, never the client's header
+ byte_size INT NOT NULL,
+ created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ deleted_at DATETIME NULL,
+ deleted_by INT NULL,
+ CONSTRAINT fk_tfu_team FOREIGN KEY (team_id) REFERENCES teams(id) ON DELETE CASCADE,
+ CONSTRAINT fk_tfu_post FOREIGN KEY (post_id) REFERENCES team_forum_posts(id) ON DELETE SET NULL,
+ CONSTRAINT fk_tfu_user FOREIGN KEY (uploader_user_id) REFERENCES users(id) ON DELETE SET NULL,
+ CONSTRAINT fk_tfu_deleter FOREIGN KEY (deleted_by) REFERENCES users(id) ON DELETE SET NULL,
+ UNIQUE KEY uq_tfu_filename (filename),
+ INDEX idx_tfu_uploader (uploader_user_id, created_at),
+ INDEX idx_tfu_sweep (deleted_at)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
-- The §2.9 approval queue. A MODERATOR performing one of the three actions that
-- publish untrusted game-sourced strings creates a pending row here; an ADMIN
-- performing one applies it immediately. Rows are kept after a decision — "a
diff --git a/server/routes.guards.json b/server/routes.guards.json
index 232ca0f..5651a8f 100644
--- a/server/routes.guards.json
+++ b/server/routes.guards.json
@@ -774,6 +774,17 @@
"validate"
]
},
+ {
+ "method": "GET",
+ "path": "/api/v1/admin/teams/:id/forum/moderation",
+ "handlers": 3,
+ "gates": [
+ "noindex",
+ "requireAuth",
+ "middleware",
+ "validate"
+ ]
+ },
{
"method": "GET",
"path": "/api/v1/admin/teams/:id/grants",
@@ -829,6 +840,26 @@
"validate"
]
},
+ {
+ "method": "GET",
+ "path": "/api/v1/admin/teams/forum/settings",
+ "handlers": 1,
+ "gates": [
+ "noindex",
+ "requireAuth"
+ ]
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/admin/teams/forum/uploads",
+ "handlers": 5,
+ "gates": [
+ "noindex",
+ "requireAuth",
+ "middleware",
+ "validate"
+ ]
+ },
{
"method": "GET",
"path": "/api/v1/admin/teams/requests",
@@ -1656,6 +1687,100 @@
"requireAuth"
]
},
+ {
+ "method": "GET",
+ "path": "/api/v1/player/teams/:slug/forum/threads",
+ "handlers": 1,
+ "gates": [
+ "noindex",
+ "requireAuth"
+ ]
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/player/teams/:slug/forum/threads",
+ "handlers": 7,
+ "gates": [
+ "noindex",
+ "requireAuth",
+ "middleware",
+ "validate"
+ ]
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/player/teams/:slug/forum/threads/:id",
+ "handlers": 3,
+ "gates": [
+ "noindex",
+ "requireAuth",
+ "middleware",
+ "validate"
+ ]
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/player/teams/:slug/forum/threads/:id/moderate",
+ "handlers": 5,
+ "gates": [
+ "noindex",
+ "requireAuth",
+ "middleware",
+ "validate"
+ ]
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/player/teams/:slug/forum/uploads",
+ "handlers": 3,
+ "gates": [
+ "noindex",
+ "requireAuth",
+ "multerMiddleware"
+ ]
+ },
+ {
+ "method": "DELETE",
+ "path": "/api/v1/player/teams/:slug/forum/uploads/:id",
+ "handlers": 3,
+ "gates": [
+ "noindex",
+ "requireAuth",
+ "middleware",
+ "validate"
+ ]
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/player/teams/:slug/grants",
+ "handlers": 1,
+ "gates": [
+ "noindex",
+ "requireAuth"
+ ]
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/player/teams/:slug/grants",
+ "handlers": 6,
+ "gates": [
+ "noindex",
+ "requireAuth",
+ "middleware",
+ "validate"
+ ]
+ },
+ {
+ "method": "DELETE",
+ "path": "/api/v1/player/teams/:slug/grants/:userId",
+ "handlers": 5,
+ "gates": [
+ "noindex",
+ "requireAuth",
+ "middleware",
+ "validate"
+ ]
+ },
{
"method": "POST",
"path": "/api/v1/public/contact",
diff --git a/server/routes.manifest.json b/server/routes.manifest.json
index 8ac8f8b..917214d 100644
--- a/server/routes.manifest.json
+++ b/server/routes.manifest.json
@@ -309,6 +309,10 @@
"method": "POST",
"path": "/api/v1/admin/teams/:id/display-name"
},
+ {
+ "method": "GET",
+ "path": "/api/v1/admin/teams/:id/forum/moderation"
+ },
{
"method": "GET",
"path": "/api/v1/admin/teams/:id/grants"
@@ -329,6 +333,14 @@
"method": "POST",
"path": "/api/v1/admin/teams/:id/unhide"
},
+ {
+ "method": "GET",
+ "path": "/api/v1/admin/teams/forum/settings"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/admin/teams/forum/uploads"
+ },
{
"method": "GET",
"path": "/api/v1/admin/teams/requests"
@@ -665,6 +677,42 @@
"method": "GET",
"path": "/api/v1/player/teams/:slug/access"
},
+ {
+ "method": "GET",
+ "path": "/api/v1/player/teams/:slug/forum/threads"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/player/teams/:slug/forum/threads"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/player/teams/:slug/forum/threads/:id"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/player/teams/:slug/forum/threads/:id/moderate"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/player/teams/:slug/forum/uploads"
+ },
+ {
+ "method": "DELETE",
+ "path": "/api/v1/player/teams/:slug/forum/uploads/:id"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/player/teams/:slug/grants"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/player/teams/:slug/grants"
+ },
+ {
+ "method": "DELETE",
+ "path": "/api/v1/player/teams/:slug/grants/:userId"
+ },
{
"method": "POST",
"path": "/api/v1/public/contact"
diff --git a/server/src/model/settings/settings.db.js b/server/src/model/settings/settings.db.js
index 07c4547..290b6f4 100644
--- a/server/src/model/settings/settings.db.js
+++ b/server/src/model/settings/settings.db.js
@@ -17,6 +17,20 @@ async function set(key, value, updatedBy = null) {
)
}
+// One row WITH its provenance. `updated_by`/`updated_at` are already stored for
+// every key; this is the only reader that needs them, because TEAMS.md §5.5.5
+// makes the uploads acknowledgement a RECORDED consent rather than a displayed
+// one, and "which admin accepted it, and when" is the question that has to be
+// answerable afterwards.
+async function getRow(key) {
+ const rows = await query(
+ 'SELECT s.`key`, s.value, s.updated_by, s.updated_at, u.username AS updated_by_username '
+ + 'FROM settings s LEFT JOIN users u ON u.id = s.updated_by WHERE s.`key` = ? LIMIT 1',
+ [key],
+ )
+ return rows[0] || null
+}
+
// Insert a default only if the key does not already exist.
async function seedDefault(key, value) {
await query('INSERT IGNORE INTO settings (`key`, value) VALUES (?, ?)', [key, value])
@@ -30,4 +44,4 @@ async function remove(key) {
await query('DELETE FROM settings WHERE `key` = ?', [key])
}
-module.exports = { getAll, get, set, seedDefault, remove }
+module.exports = { getAll, get, getRow, set, seedDefault, remove }
diff --git a/server/src/model/settings/settings.model.js b/server/src/model/settings/settings.model.js
index 53f10a9..ded35a8 100644
--- a/server/src/model/settings/settings.model.js
+++ b/server/src/model/settings/settings.model.js
@@ -16,6 +16,18 @@ const PUBLIC_KEYS = [
'theme_visual', // preset/custom colors, fonts, radii (JSON). See THEMING_AND_NAV.md §6.1.
'brand_assets', // uploaded logo/hero/favicon overrides (JSON). §6.3.
'nav_public', // public site nav overrides (JSON). §6.4.
+ // The two Team-forum controls (TEAMS.md §5.5.6). The client needs the first to
+ // know whether to render the forum panel at all, and the second to decide which
+ // composer to show — an upload control that 404s is worse than no control.
+ // Neither is sensitive.
+ //
+ // `teams_forum_uploads_ack` is deliberately NOT here: who accepted a liability
+ // notice is operator detail, exactly as `failure_reason` is in MODULE_API.md
+ // §2.9. And publishing the mode does not move the DECISION client-side — the
+ // server still resolves what renders (§5.5.3); the client is only told which
+ // composer to draw.
+ 'teams_forums_enabled',
+ 'teams_forum_images',
]
// Admin-configurable theming & navigation (docs/website/THEMING_AND_NAV.md).
diff --git a/server/src/model/teams/teamAccess.db.js b/server/src/model/teams/teamAccess.db.js
index e21fe9a..6ae8496 100644
--- a/server/src/model/teams/teamAccess.db.js
+++ b/server/src/model/teams/teamAccess.db.js
@@ -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,
diff --git a/server/src/model/teams/teamForum.db.js b/server/src/model/teams/teamForum.db.js
new file mode 100644
index 0000000..0647f30
--- /dev/null
+++ b/server/src/model/teams/teamForum.db.js
@@ -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,
+}
diff --git a/server/src/model/teams/teamForum.model.js b/server/src/model/teams/teamForum.model.js
new file mode 100644
index 0000000..2f8316f
--- /dev/null
+++ b/server/src/model/teams/teamForum.model.js
@@ -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,
+}
diff --git a/server/src/model/teams/teamForumSettings.model.js b/server/src/model/teams/teamForumSettings.model.js
new file mode 100644
index 0000000..b3d69dc
--- /dev/null
+++ b/server/src/model/teams/teamForumSettings.model.js
@@ -0,0 +1,143 @@
+// ── The operator's two forum controls, and the acknowledgement gate ────────
+//
+// TEAMS.md §5.5. Three `settings` keys, and the reason they live in their own
+// file rather than in settings.model.js is that only one of them is an ordinary
+// key: `teams_forum_images` has a server-side precondition, and a precondition
+// buried in the generic setMany() loop is one nobody reading that loop would
+// know about.
+//
+// teams_forums_enabled '0' | '1' default '0' — off
+// teams_forum_images 'disabled' | 'remote' | 'uploads' default 'disabled'
+// teams_forum_uploads_ack the acknowledged TEXT VERSION absent until given
+//
+// **Both reads fail closed.** A DB fault reports the forum off and images
+// disabled, because the alternative is a transient error opening a feature the
+// operator turned off, or rendering third-party images on a site whose operator
+// chose not to. The cost of failing closed here is a forum that 404s for a minute;
+// the cost of failing open is a policy that is not a policy.
+
+const settingsDb = require('../settings/settings.db')
+
+const ENABLED_KEY = 'teams_forums_enabled'
+const IMAGES_KEY = 'teams_forum_images'
+const ACK_KEY = 'teams_forum_uploads_ack'
+
+const IMAGE_MODES = ['disabled', 'remote', 'uploads']
+
+// The version of the §5.5.5 warning text currently in force. Bumping this is what
+// makes every stored acknowledgement stale — see `ackState` below for what that
+// then does, which is deliberately NOT "turn uploads off".
+const ACK_VERSION = '1'
+
+/** Is the forum switched on? Fail closed. */
+async function forumsEnabled() {
+ try {
+ return String(await settingsDb.get(ENABLED_KEY)) === '1'
+ } catch {
+ return false
+ }
+}
+
+/**
+ * The image policy. Fail closed, and coerce any unexpected stored value back to
+ * 'disabled' — a hand-edited row must not be able to widen the policy by being
+ * unreadable.
+ */
+async function imageMode() {
+ try {
+ const value = await settingsDb.get(IMAGES_KEY)
+ return IMAGE_MODES.includes(value) ? value : 'disabled'
+ } catch {
+ return 'disabled'
+ }
+}
+
+/** Are uploads accepted? The one mode where files come to rest on the operator's disk. */
+async function uploadsEnabled() {
+ return (await imageMode()) === 'uploads'
+}
+
+/**
+ * The acknowledgement's state, for the admin surface.
+ *
+ * `stale` is the case §5.5.5 spends its longest paragraph on: the text was
+ * reworded after an operator accepted it. Neither obvious answer is right —
+ * silently downgrading a live feature because a legal text changed strands users
+ * mid-conversation, and honouring an old acceptance forever defeats versioning.
+ * So uploads keep working, `stale` drives a persistent banner, and
+ * `assertSettingsWritable` below refuses every other forum setting until it is
+ * re-given. Non-destructive, and impossible to ignore.
+ */
+async function ackState() {
+ const stored = await settingsDb.get(ACK_KEY)
+ const row = await settingsDb.getRow(ACK_KEY)
+ return {
+ version: ACK_VERSION,
+ acknowledgedVersion: stored ?? null,
+ given: stored != null,
+ stale: stored != null && String(stored) !== ACK_VERSION,
+ ...(row ? { acknowledgedBy: row.updated_by_username ?? null, acknowledgedAt: row.updated_at } : {}),
+ }
+}
+
+/**
+ * The gate. `PUT teams_forum_images = 'uploads'` is rejected 400 unless the SAME
+ * request carries `acknowledge:
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
diff --git a/server/src/server.js b/server/src/server.js
index 4474214..9b4dbce 100644
--- a/server/src/server.js
+++ b/server/src/server.js
@@ -10,6 +10,7 @@ const http = require('http')
const botScore = require('./middleware/botScore')
const announceWorker = require('./utils/announceWorker')
const teamActivityPrune = require('./utils/teamActivityPrune')
+const teamForumUploadSweep = require('./utils/teamForumUploadSweep')
const { ensureSchema, close } = require('./utils/db')
const { seedDefaults, createInitialAdminFromEnv } = require('../db/seed')
const settings = require('./model/settings/settings.model')
@@ -155,6 +156,7 @@ async function start() {
// is the obvious unbounded-growth failure, so retention starts with the feed
// rather than after someone notices. No-op on a deployment with no Teams.
teamActivityPrune.start()
+ teamForumUploadSweep.start()
setupShutdown(server, internalServer)
}
@@ -174,6 +176,7 @@ function setupShutdown(server, internalServer) {
botScore.stopSweeper() // stop the bot-store cleanup interval
announceWorker.stop() // stop the news-announcement dispatcher poller
teamActivityPrune.stop() // stop the Team activity retention timer
+ teamForumUploadSweep.stop() // stop the forum upload sweep
server.close(() => log.info('http server closed'))
if (internalServer) internalServer.close(() => log.info('internal http server closed'))
try {
diff --git a/server/src/utils/forumHtml.js b/server/src/utils/forumHtml.js
new file mode 100644
index 0000000..d6e0b4c
--- /dev/null
+++ b/server/src/utils/forumHtml.js
@@ -0,0 +1,193 @@
+// ── The forum's own HTML profile, and core's image renderer ────────────────
+//
+// TEAMS.md §5.5.3, which is the load-bearing decision of the whole forum design
+// and is deliberately NOT how the rest of the site works.
+//
+// **The author never writes an `
` tag.** Core's shared sanitizer
+// (utils/sanitizeHtml.js) allows `
` from any http/https host — it is tuned
+// for rich text from the ADMIN editor, where the author is already trusted.
+// Handing that profile to arbitrary players would make `teams_forum_images`
+// unenforceable: every post could hotlink in every mode and the setting would be
+// decoration. So the forum derives its own profile in which `img` is never an
+// allowed tag, in any mode.
+//
+// What an author writes is a URL. What decides whether it becomes a picture is
+// this file's renderer, at READ time:
+//
+// author types: https://example.com/banner.png
+// stored HTML: https://…
+// rendered: that link, and — in `remote`/`uploads` mode only — a
+// core-generated
beneath it
+//
+// Five properties fall out, and they are the reason for the design:
+//
+// 1. The policy is ENFORCEABLE, because the only code that can emit an
+// is this file.
+// 2. Flipping the setting back to `disabled` retroactively un-renders every
+// image on every existing post, with NO data migration — the images were
+// never in the stored HTML.
+// 3. No attribute smuggling: no author-supplied srcset, onerror, width=99999
+// or style. Core emits a fixed attribute set.
+// 4. The link always survives. A blocked, dead or 404ing image degrades to the
+// URL the author actually wrote, which is what the reader wanted anyway.
+// 5. It matches how forums conventionally behave.
+//
+// **Never proxy or cache a remote image server-side.** The moment the server
+// fetches a user-supplied URL it is an SSRF vector, and an allow-set is useless
+// here because the whole point is arbitrary hosts. The browser fetches; the
+// server never does. Written down so nobody adds a proxy "for performance".
+
+const sanitizeHtml = require('sanitize-html')
+
+// Derived from the shared profile with the image family removed. `figure` and
+// `figcaption` go with `img` rather than surviving it: without an image inside,
+// a figure is an empty box, and leaving them would let an author build a caption
+// for a picture core decided not to render.
+const FORUM_OPTIONS = {
+ allowedTags: [
+ 'h3', 'h4', 'h5', 'h6',
+ 'p', 'br', 'hr', 'blockquote', 'pre', 'code',
+ 'ul', 'ol', 'li',
+ 'strong', 'b', 'em', 'i', 'u', 's', 'sup', 'sub', 'mark', 'span',
+ 'a',
+ 'table', 'thead', 'tbody', 'tr', 'th', 'td',
+ ],
+ allowedAttributes: {
+ // `rel` is allowed only so the transform below can WRITE it — an author's own
+ // rel is overwritten, not merged. Without it here, sanitize-html strips the
+ // very attribute the transform just added and every link ships without
+ // noopener.
+ a: ['href', 'title', 'rel'],
+ th: ['colspan', 'rowspan'],
+ td: ['colspan', 'rowspan'],
+ },
+ // No `style` at all, and therefore no allowedStyles. The shared profile permits
+ // text-align for the admin editor's block alignment; a forum post has no such
+ // editor and every style attribute a player could send is one more thing to
+ // reason about.
+ allowedSchemes: ['http', 'https', 'mailto'],
+ allowProtocolRelative: false,
+ transformTags: {
+ a: sanitizeHtml.simpleTransform('a', { rel: 'noopener noreferrer nofollow' }, true),
+ },
+ disallowedTagsMode: 'discard',
+}
+
+// What may become a picture. Conservative on purpose: guessing wrong renders an
+//
pointed at something that is not an image, which reads as a broken site.
+const IMAGE_EXTENSIONS = ['.png', '.jpg', '.jpeg', '.gif', '.webp', '.avif']
+
+// Tags whose text is left alone by the linkifier. Inside an anchor because
+// nesting one is invalid; inside code/pre because a URL in a code sample is
+// being shown, not offered.
+const NO_LINKIFY = new Set(['a', 'code', 'pre'])
+
+const BARE_URL = /\bhttps?:\/\/[^\s<>"']+/g
+
+/**
+ * Sanitise a forum post body. Runs on WRITE; the stored value is already safe and
+ * is served without re-sanitising — the same contract the wiki and the CMS follow.
+ */
+function cleanForumBody(html) {
+ if (html == null || html === '') return html
+ return linkify(sanitizeHtml(String(html), FORUM_OPTIONS))
+}
+
+/**
+ * Turn bare URLs in text into anchors.
+ *
+ * Runs AFTER sanitising, over the sanitiser's own output, and only on text
+ * outside tags. That ordering is what makes it safe: every text node has already
+ * been HTML-escaped, so the matched URL can go into both the href and the link
+ * text unchanged — `&` is already `&`, which is what an attribute wants.
+ */
+function linkify(html) {
+ const tokens = String(html).split(/(<[^>]+>)/)
+ const openStack = []
+ return tokens
+ .map((token) => {
+ if (token.startsWith('<')) {
+ const match = /^<\s*(\/?)\s*([a-zA-Z0-9]+)/.exec(token)
+ if (match) {
+ const [, closing, name] = match
+ const tag = name.toLowerCase()
+ if (closing) {
+ const at = openStack.lastIndexOf(tag)
+ if (at !== -1) openStack.splice(at, 1)
+ } else if (!token.endsWith('/>')) {
+ openStack.push(tag)
+ }
+ }
+ return token
+ }
+ if (openStack.some((tag) => NO_LINKIFY.has(tag))) return token
+ return token.replace(BARE_URL, (url) => {
+ // Trailing punctuation is far more likely to be the sentence's than the
+ // URL's — "see https://example.com." should not link the full stop.
+ const trimmed = url.replace(/[.,;:!?)\]]+$/, '')
+ const tail = url.slice(trimmed.length)
+ return `${trimmed}${tail}`
+ })
+ })
+ .join('')
+}
+
+/**
+ * May this URL become a picture?
+ *
+ * `https:` only, because the CSP is `img-src 'self' data: https:` (config/csp.js)
+ * — an `http:` image is blocked by the browser and renders as a broken picture,
+ * so an `http:` URL stays a plain link. This is a real mismatch with the SHARED
+ * sanitizer, which permits `http` for `img`, and it is exactly the sort of thing
+ * that presents as "images are broken on my forum" with nothing in any log.
+ *
+ * Same-origin `/uploads/…` paths are embeddable too — that is where `uploads`
+ * mode puts a file, and `'self'` covers them under the same CSP.
+ */
+function isEmbeddableImageUrl(href) {
+ if (typeof href !== 'string' || href === '') return false
+ const decoded = href.replace(/&/g, '&')
+ let pathname
+ if (decoded.startsWith('/uploads/')) {
+ pathname = decoded.split(/[?#]/)[0]
+ } else {
+ let url
+ try {
+ url = new URL(decoded)
+ } catch {
+ return false
+ }
+ if (url.protocol !== 'https:') return false
+ pathname = url.pathname
+ }
+ const lower = pathname.toLowerCase()
+ return IMAGE_EXTENSIONS.some((ext) => lower.endsWith(ext))
+}
+
+/**
+ * Render a stored body for one viewer under one image policy.
+ *
+ * `disabled` returns the stored HTML byte-for-byte. The other two append a core-
+ * generated
after each anchor whose href looks like an image — which is why
+ * the stored HTML is identical between the three modes, the property this whole
+ * design exists to give.
+ */
+function renderForumBody(storedHtml, mode) {
+ if (storedHtml == null || storedHtml === '') return storedHtml
+ if (mode !== 'remote' && mode !== 'uploads') return storedHtml
+ return String(storedHtml).replace(/]*href="([^"]*)"[^>]*>.*?<\/a>/gi, (anchor, href) => {
+ if (!isEmbeddableImageUrl(href)) return anchor
+ // A fixed attribute set, every time. `no-referrer` limits what leaks to the
+ // third-party host — it cannot prevent the request itself, which is the
+ // privacy cost stated in the admin help text rather than hidden.
+ return `${anchor}
`
+ })
+}
+
+module.exports = {
+ cleanForumBody,
+ renderForumBody,
+ isEmbeddableImageUrl,
+ IMAGE_EXTENSIONS,
+ FORUM_OPTIONS,
+}
diff --git a/server/src/utils/teamForumUploadSweep.js b/server/src/utils/teamForumUploadSweep.js
new file mode 100644
index 0000000..5d6e886
--- /dev/null
+++ b/server/src/utils/teamForumUploadSweep.js
@@ -0,0 +1,66 @@
+// ── Team forum upload sweep ────────────────────────────────────────────────
+//
+// TEAMS.md §5.5.4's lifecycle half: soft-deleted uploads lose their bytes after a
+// retention window, and files uploaded into a composer that was never submitted
+// lose theirs after a grace period. The existing admin upload path never deletes
+// anything, which is fine at admin volume and is not fine once a community can
+// upload.
+//
+// Same in-process shape as utils/teamActivityPrune — setInterval + unref + stop(),
+// wired into server.js start/shutdown. There is no cron in this stack.
+//
+// **It runs whether or not `teams_forum_images` is `uploads`, and that is the
+// point.** An operator who turns uploads off after a problem has files already on
+// disk; a sweep that switched itself off with the setting would strand exactly the
+// bytes they were trying to be rid of. The admin help text says the same thing in
+// the other direction — disabling uploads stops new files, it does not delete old
+// ones — and this is the only thing that eventually does.
+
+const uploads = require('../model/teams/teamForumUploads.model')
+const log = require('./logger')('teams')
+
+const INTERVAL_MS = Number(process.env.TEAM_FORUM_SWEEP_MS) || 24 * 60 * 60 * 1000
+// Later than the activity prune's five minutes, so two table-walking jobs do not
+// land on the same boot at the same moment.
+const FIRST_RUN_MS = Number(process.env.TEAM_FORUM_SWEEP_DELAY_MS) || 10 * 60 * 1000
+
+let timer = null
+let firstRun = null
+
+/** One sweep. Never throws — it runs on a timer with nobody to catch it. */
+async function tick() {
+ try {
+ const result = await uploads.sweep()
+ if (result.swept) log.info('team forum upload sweep', result)
+ return result
+ } catch (err) {
+ log.error('team forum upload sweep failed', { message: err.message })
+ return null
+ }
+}
+
+function start() {
+ if (timer || firstRun) return timer
+ firstRun = setTimeout(() => {
+ firstRun = null
+ tick()
+ timer = setInterval(() => { tick() }, INTERVAL_MS)
+ if (timer.unref) timer.unref()
+ }, FIRST_RUN_MS)
+ if (firstRun.unref) firstRun.unref()
+ log.info('team forum upload sweep started', { intervalMs: INTERVAL_MS, firstRunMs: FIRST_RUN_MS })
+ return timer
+}
+
+function stop() {
+ if (firstRun) {
+ clearTimeout(firstRun)
+ firstRun = null
+ }
+ if (timer) {
+ clearInterval(timer)
+ timer = null
+ }
+}
+
+module.exports = { start, stop, tick, INTERVAL_MS, FIRST_RUN_MS }
diff --git a/server/swagger/swagger-output.json b/server/swagger/swagger-output.json
index 919ed8d..6b0f874 100644
--- a/server/swagger/swagger-output.json
+++ b/server/swagger/swagger-output.json
@@ -4352,6 +4352,112 @@
]
}
},
+ "/api/v1/admin/teams/forum/settings": {
+ "get": {
+ "tags": [
+ "Admin · Teams"
+ ],
+ "summary": "The forum switch, the image policy, and the acknowledgement’s state",
+ "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.",
+ "responses": {
+ "200": {
+ "description": "Forum settings state",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/TeamForumSettingsState"
+ }
+ }
+ }
+ }
+ },
+ "security": [
+ {
+ "cookieAuth": []
+ },
+ {
+ "bearerAuth": []
+ }
+ ]
+ }
+ },
+ "/api/v1/admin/teams/forum/uploads": {
+ "get": {
+ "tags": [
+ "Admin · Teams"
+ ],
+ "summary": "Upload attribution across every Team forum",
+ "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.",
+ "parameters": [
+ {
+ "name": "limit",
+ "in": "query",
+ "required": false,
+ "schema": {
+ "type": "integer"
+ },
+ "description": "Page size (default 100)."
+ },
+ {
+ "name": "offset",
+ "in": "query",
+ "required": false,
+ "schema": {
+ "type": "integer"
+ },
+ "description": "Rows to skip (default 0)."
+ },
+ {
+ "name": "deleted",
+ "in": "query",
+ "required": false,
+ "schema": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "string"
+ },
+ "enum": {
+ "type": "array",
+ "example": [
+ "0",
+ "1"
+ ],
+ "items": {
+ "type": "string"
+ }
+ }
+ }
+ },
+ "description": "Include soft-deleted uploads."
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "Uploads with their attribution",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/TeamForumUploadList"
+ }
+ }
+ }
+ },
+ "400": {
+ "description": "Bad Request"
+ }
+ },
+ "security": [
+ {
+ "cookieAuth": []
+ },
+ {
+ "bearerAuth": []
+ }
+ ]
+ }
+ },
"/api/v1/admin/teams/requests": {
"get": {
"tags": [
@@ -4729,6 +4835,59 @@
}
}
},
+ "/api/v1/admin/teams/{id}/forum/moderation": {
+ "get": {
+ "tags": [
+ "Admin · Teams"
+ ],
+ "summary": "A Team’s forum moderation ledger",
+ "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.",
+ "parameters": [
+ {
+ "name": "id",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "integer"
+ },
+ "description": "The Team id."
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "The ledger, newest first",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/TeamForumModerationLedger"
+ }
+ }
+ }
+ },
+ "400": {
+ "description": "Bad Request"
+ },
+ "404": {
+ "description": "No such Team",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ }
+ }
+ },
+ "security": [
+ {
+ "cookieAuth": []
+ },
+ {
+ "bearerAuth": []
+ }
+ ]
+ }
+ },
"/api/v1/admin/teams/{id}/grants": {
"get": {
"tags": [
@@ -10093,6 +10252,773 @@
]
}
},
+ "/api/v1/player/teams/{slug}/forum/threads": {
+ "get": {
+ "tags": [
+ "Player · Teams"
+ ],
+ "summary": "List a Team forum’s threads",
+ "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.",
+ "parameters": [
+ {
+ "name": "slug",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string"
+ },
+ "description": "The Team slug."
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "The thread list, with what this caller may do",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/TeamForumThreadList"
+ }
+ }
+ }
+ },
+ "401": {
+ "description": "Unauthorized"
+ },
+ "403": {
+ "description": "Forbidden"
+ },
+ "404": {
+ "description": "Forum off, no such Team, or no access",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ }
+ },
+ "500": {
+ "description": "Internal Server Error"
+ }
+ },
+ "security": [
+ {
+ "cookieAuth": []
+ },
+ {
+ "bearerAuth": []
+ }
+ ]
+ },
+ "post": {
+ "tags": [
+ "Player · Teams"
+ ],
+ "summary": "Post an announcement",
+ "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.",
+ "parameters": [
+ {
+ "name": "slug",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string"
+ },
+ "description": "The Team slug."
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "Posted",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "properties": {
+ "ok": {
+ "type": "boolean"
+ },
+ "threadId": {
+ "type": "integer"
+ }
+ }
+ }
+ }
+ }
+ },
+ "400": {
+ "description": "Bad Request"
+ },
+ "401": {
+ "description": "Unauthorized"
+ },
+ "403": {
+ "description": "Not a leader of this Team",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ }
+ },
+ "404": {
+ "description": "Not Found"
+ },
+ "500": {
+ "description": "Internal Server Error"
+ }
+ },
+ "security": [
+ {
+ "cookieAuth": []
+ },
+ {
+ "bearerAuth": []
+ }
+ ],
+ "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"
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/api/v1/player/teams/{slug}/forum/threads/{id}": {
+ "get": {
+ "tags": [
+ "Player · Teams"
+ ],
+ "summary": "Read one thread and its posts",
+ "description": "Post bodies are rendered under the CURRENT image policy: `disabled` serves the stored HTML unchanged, `remote` and `uploads` add a core-generated
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.",
+ "parameters": [
+ {
+ "name": "slug",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string"
+ },
+ "description": "The Team slug."
+ },
+ {
+ "name": "id",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "integer"
+ },
+ "description": "The thread id."
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "The thread",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/TeamForumThread"
+ }
+ }
+ }
+ },
+ "400": {
+ "description": "Bad Request"
+ },
+ "401": {
+ "description": "Unauthorized"
+ },
+ "403": {
+ "description": "Forbidden"
+ },
+ "404": {
+ "description": "Forum off, no such thread, or no access",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ }
+ },
+ "500": {
+ "description": "Internal Server Error"
+ }
+ },
+ "security": [
+ {
+ "cookieAuth": []
+ },
+ {
+ "bearerAuth": []
+ }
+ ]
+ }
+ },
+ "/api/v1/player/teams/{slug}/forum/threads/{id}/moderate": {
+ "post": {
+ "tags": [
+ "Player · Teams"
+ ],
+ "summary": "Pin, lock, hide or delete a thread",
+ "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.",
+ "parameters": [
+ {
+ "name": "slug",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string"
+ },
+ "description": "The Team slug."
+ },
+ {
+ "name": "id",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "integer"
+ },
+ "description": "The thread id."
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "Applied",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "properties": {
+ "ok": {
+ "type": "boolean"
+ },
+ "action": {
+ "type": "string"
+ },
+ "threadId": {
+ "type": "integer"
+ }
+ }
+ }
+ }
+ }
+ },
+ "400": {
+ "description": "Bad Request"
+ },
+ "401": {
+ "description": "Unauthorized"
+ },
+ "403": {
+ "description": "Not a leader of this Team",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ }
+ },
+ "404": {
+ "description": "Not Found"
+ },
+ "500": {
+ "description": "Internal Server Error"
+ }
+ },
+ "security": [
+ {
+ "cookieAuth": []
+ },
+ {
+ "bearerAuth": []
+ }
+ ],
+ "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
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/api/v1/player/teams/{slug}/forum/uploads": {
+ "post": {
+ "tags": [
+ "Player · Teams"
+ ],
+ "summary": "Upload an image to a Team forum",
+ "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.",
+ "parameters": [
+ {
+ "name": "slug",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string"
+ },
+ "description": "The Team slug."
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "Stored",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "properties": {
+ "ok": {
+ "type": "boolean"
+ },
+ "id": {
+ "type": "integer"
+ },
+ "url": {
+ "type": "string"
+ },
+ "bytes": {
+ "type": "integer"
+ }
+ }
+ }
+ }
+ }
+ },
+ "400": {
+ "description": "Not the image type it claims to be",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ }
+ },
+ "401": {
+ "description": "Unauthorized"
+ },
+ "403": {
+ "description": "Forbidden"
+ },
+ "404": {
+ "description": "Not Found"
+ },
+ "429": {
+ "description": "Daily upload quota reached",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ }
+ },
+ "500": {
+ "description": "Internal Server Error"
+ }
+ },
+ "security": [
+ {
+ "cookieAuth": []
+ },
+ {
+ "bearerAuth": []
+ }
+ ],
+ "requestBody": {
+ "required": true,
+ "content": {
+ "multipart/form-data": {
+ "schema": {
+ "type": "object",
+ "properties": {
+ "image": {
+ "type": "string",
+ "format": "binary"
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/api/v1/player/teams/{slug}/forum/uploads/{id}": {
+ "delete": {
+ "tags": [
+ "Player · Teams"
+ ],
+ "summary": "Remove an uploaded image",
+ "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.",
+ "parameters": [
+ {
+ "name": "slug",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string"
+ },
+ "description": "The Team slug."
+ },
+ {
+ "name": "id",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "integer"
+ },
+ "description": "The upload id."
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "Removed",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "properties": {
+ "ok": {
+ "type": "boolean"
+ }
+ }
+ }
+ }
+ }
+ },
+ "400": {
+ "description": "Bad Request"
+ },
+ "401": {
+ "description": "Unauthorized"
+ },
+ "403": {
+ "description": "Not your upload",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ }
+ },
+ "404": {
+ "description": "Not Found"
+ },
+ "500": {
+ "description": "Internal Server Error"
+ }
+ },
+ "security": [
+ {
+ "cookieAuth": []
+ },
+ {
+ "bearerAuth": []
+ }
+ ]
+ }
+ },
+ "/api/v1/player/teams/{slug}/grants": {
+ "get": {
+ "tags": [
+ "Player · Teams"
+ ],
+ "summary": "The Team’s forum guests, and the per-Team cap",
+ "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.",
+ "parameters": [
+ {
+ "name": "slug",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string"
+ },
+ "description": "The Team slug."
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "Forum guests",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/TeamForumGuestList"
+ }
+ }
+ }
+ },
+ "401": {
+ "description": "Unauthorized"
+ },
+ "403": {
+ "description": "Not a leader of this Team",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ }
+ },
+ "404": {
+ "description": "Not Found"
+ },
+ "500": {
+ "description": "Internal Server Error"
+ }
+ },
+ "security": [
+ {
+ "cookieAuth": []
+ },
+ {
+ "bearerAuth": []
+ }
+ ]
+ },
+ "post": {
+ "tags": [
+ "Player · Teams"
+ ],
+ "summary": "Grant forum access to an account",
+ "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.",
+ "parameters": [
+ {
+ "name": "slug",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string"
+ },
+ "description": "The Team slug."
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "Granted",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "properties": {
+ "ok": {
+ "type": "boolean"
+ },
+ "grantee": {
+ "type": "string"
+ },
+ "warning": {
+ "type": "string"
+ }
+ }
+ }
+ }
+ }
+ },
+ "400": {
+ "description": "Bad Request"
+ },
+ "401": {
+ "description": "Unauthorized"
+ },
+ "403": {
+ "description": "Forbidden"
+ },
+ "404": {
+ "description": "Not Found"
+ },
+ "409": {
+ "description": "Already granted, or the Team is at its cap",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ }
+ },
+ "500": {
+ "description": "Internal Server Error"
+ }
+ },
+ "security": [
+ {
+ "cookieAuth": []
+ },
+ {
+ "bearerAuth": []
+ }
+ ],
+ "requestBody": {
+ "required": true,
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "properties": {
+ "userId": {
+ "type": "integer"
+ },
+ "username": {
+ "type": "string"
+ },
+ "reason": {
+ "type": "string",
+ "maxLength": 255
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/api/v1/player/teams/{slug}/grants/{userId}": {
+ "delete": {
+ "tags": [
+ "Player · Teams"
+ ],
+ "summary": "Revoke forum access",
+ "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.",
+ "parameters": [
+ {
+ "name": "slug",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string"
+ },
+ "description": "The Team slug."
+ },
+ {
+ "name": "userId",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "integer"
+ },
+ "description": "The grantee’s account id."
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "Revoked",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "properties": {
+ "ok": {
+ "type": "boolean"
+ },
+ "grantee": {
+ "type": "string"
+ }
+ }
+ }
+ }
+ }
+ },
+ "400": {
+ "description": "Bad Request"
+ },
+ "401": {
+ "description": "Unauthorized"
+ },
+ "403": {
+ "description": "Not a leader, or the grant was staff-issued",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ }
+ },
+ "404": {
+ "description": "Not Found"
+ },
+ "500": {
+ "description": "Internal Server Error"
+ }
+ },
+ "security": [
+ {
+ "cookieAuth": []
+ },
+ {
+ "bearerAuth": []
+ }
+ ],
+ "requestBody": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "properties": {
+ "reason": {
+ "example": "any"
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ },
"/api/v1/public/contact": {
"post": {
"tags": [
diff --git a/server/test/teamForum.test.js b/server/test/teamForum.test.js
new file mode 100644
index 0000000..fea740d
--- /dev/null
+++ b/server/test/teamForum.test.js
@@ -0,0 +1,332 @@
+// The forum's access model, its switches, and its renderer
+// (docs/website/TEAMS.md Part 5, phase 4 "5a").
+//
+// The four tests named "acceptance" are §Phase 4's four acceptance criteria,
+// verbatim. They are the ones to read first, and the ones not to weaken: each
+// names a property that the code around it can lose without any screen looking
+// different.
+const { test, beforeEach, afterEach } = require('node:test')
+const assert = require('node:assert/strict')
+
+const forumSettings = require('../src/model/teams/teamForumSettings.model')
+const settingsDb = require('../src/model/settings/settings.db')
+const accessDb = require('../src/model/teams/teamAccess.db')
+const teamsDb = require('../src/model/teams/teams.db')
+const usersDb = require('../src/model/users/users.db')
+const grants = require('../src/model/teams/teamGrants.model')
+const access = require('../src/model/teams/teamAccess.model')
+const forum = require('../src/model/teams/teamForum.model')
+const forumDb = require('../src/model/teams/teamForum.db')
+const uploads = require('../src/model/teams/teamForumUploads.model')
+const { cleanForumBody, renderForumBody } = require('../src/utils/forumHtml')
+
+const saved = []
+function patch(mod, name, fn) {
+ saved.push([mod, name, mod[name]])
+ mod[name] = fn
+}
+
+// One settings store per test, so a test states the keys it cares about and
+// nothing else. `get` returning undefined is "the row does not exist", which for
+// both forum keys is the default and therefore the OFF state.
+let store = {}
+function stubSettings() {
+ store = {}
+ patch(settingsDb, 'get', async (key) => store[key])
+ patch(settingsDb, 'getRow', async (key) => (key in store
+ ? { key, value: store[key], updated_by: 1, updated_by_username: 'root', updated_at: new Date() }
+ : null))
+ patch(settingsDb, 'set', async (key, value) => { store[key] = value })
+}
+
+beforeEach(() => { stubSettings() })
+afterEach(() => {
+ while (saved.length) {
+ const [mod, name, original] = saved.pop()
+ mod[name] = original
+ }
+})
+
+// ── the switch (§5.5.1) ────────────────────────────────────────────────────
+
+test('the forum is off until an operator turns it on, and a broken read keeps it off', async () => {
+ assert.equal(await forumSettings.forumsEnabled(), false)
+ store.teams_forums_enabled = '1'
+ assert.equal(await forumSettings.forumsEnabled(), true)
+
+ // Fail closed. A transient DB fault must not open a feature the operator
+ // deliberately turned off — a forum that 404s for a minute is the cheap failure.
+ patch(settingsDb, 'get', async () => { throw new Error('db down') })
+ assert.equal(await forumSettings.forumsEnabled(), false)
+})
+
+test('an unexpected stored image mode reads as disabled rather than as itself', async () => {
+ store.teams_forum_images = 'everything'
+ assert.equal(await forumSettings.imageMode(), 'disabled')
+})
+
+// ── the acknowledgement gate (§5.5.5) ──────────────────────────────────────
+
+test('acceptance 4: uploads mode is rejected without a matching acknowledgement', () => {
+ // Server-side, with the admin UI's checkbox bypassed — a checkbox is how the
+ // gate is presented and never the gate.
+ const refused = forumSettings.assertAcknowledged('uploads', undefined)
+ assert.equal(refused.ok, false)
+ assert.equal(refused.status, 400)
+
+ // A STALE version is not an acknowledgement either.
+ assert.equal(forumSettings.assertAcknowledged('uploads', '0').ok, false)
+ assert.equal(forumSettings.assertAcknowledged('uploads', forumSettings.ACK_VERSION).ok, true)
+})
+
+test('the other two image modes need no acknowledgement', () => {
+ // `remote` gets a non-blocking advisory instead: nothing comes to rest on the
+ // operator's disk, which is the thing the acknowledgement is about.
+ assert.equal(forumSettings.assertAcknowledged('remote', undefined).ok, true)
+ assert.equal(forumSettings.assertAcknowledged('disabled', undefined).ok, true)
+})
+
+test('a reworded notice freezes forum settings but does NOT disable uploads', async () => {
+ store.teams_forum_uploads_ack = '0' // accepted an older wording
+ store.teams_forum_images = 'uploads'
+
+ const state = await forumSettings.ackState()
+ assert.equal(state.stale, true)
+ assert.equal(state.given, true)
+ // Uploads keep working: silently downgrading a live feature because a legal
+ // text changed would strand users mid-conversation.
+ assert.equal(await forumSettings.uploadsEnabled(), true)
+
+ const frozen = await forumSettings.assertSettingsWritable(['teams_forums_enabled'], undefined)
+ assert.equal(frozen.ok, false)
+ // Re-acknowledging is the key to its own lock.
+ const unlocked = await forumSettings.assertSettingsWritable(
+ ['teams_forums_enabled'], forumSettings.ACK_VERSION,
+ )
+ assert.equal(unlocked.ok, true)
+})
+
+test('a setting that is not the forum’s is unaffected by a stale acknowledgement', async () => {
+ store.teams_forum_uploads_ack = '0'
+ const result = await forumSettings.assertSettingsWritable(['site_title'], undefined)
+ assert.equal(result.ok, true)
+})
+
+// ── the renderer (§5.5.3) ──────────────────────────────────────────────────
+
+test('acceptance 3: the stored HTML is identical in every image mode', () => {
+ const stored = cleanForumBody('
is in the
+ // stored body in any mode, which is what makes the policy enforceable and what
+ // makes flipping it back a no-op rather than a migration.
+ assert.ok(!stored.includes('
{
+ const stored = cleanForumBody(
+ '
is core's renderer.
+ assert.ok(!renderForumBody(stored, 'uploads').includes('
{
+ // CSP is `img-src 'self' data: https:` — an http: image is blocked by the
+ // browser and renders as a broken picture, so it is never embedded. This
+ // presents as "images are broken on my forum" with nothing in any log, which is
+ // why it is asserted rather than assumed.
+ const httpUrl = renderForumBody(cleanForumBody('
https://x.test/a.exe
https://example.com/a.png') + assert.ok(!stored.includes(' { + const stored = cleanForumBody('x') + assert.match(stored, /rel="noopener noreferrer nofollow"/) + assert.ok(!stored.includes('rel="me"')) +}) + +// ── grants: authority, the cap, and non-contamination (§2.5) ─────────────── + +const team = { id: 1, name: 'Ossuary' } +const leader = { id: 7, username: 'aldric', role: 'player' } +const staff = { id: 2, username: 'root', role: 'admin' } +const guest = { id: 9, username: 'mara', role: 'player' } + +function stubGrantWorld({ leaderIds = [7], existing = null, activeCount = 0 } = {}) { + patch(access, 'isLeaderByUser', async (_teamId, userId) => leaderIds.includes(userId)) + patch(accessDb, 'activeGrant', async () => existing) + patch(accessDb, 'activeGrantCount', async () => activeCount) + patch(usersDb, 'findByUsername', async (name) => (name === guest.username ? guest : null)) + patch(usersDb, 'findById', async (id) => [leader, staff, guest].find((u) => u.id === id) || null) +} + +test('acceptance 1: a granted account has forum access and is not a member', async () => { + stubGrantWorld() + const written = [] + patch(accessDb, 'insertGrant', async (row) => { written.push(row); return 1 }) + // The membership projection is stubbed to a table nothing may write. If the + // grant path touched it, these would be the rows that changed. + const membersBefore = [] + patch(teamsDb, 'membersByTeam', async () => membersBefore) + patch(teamsDb, 'activeByUser', async () => undefined) + + const result = await grants.grant({ team, actor: leader, username: 'mara' }) + assert.equal(result.ok, true) + assert.equal(written.length, 1) + assert.deepEqual(membersBefore, []) // byte-identical member rows across the cycle + + // The resolver now says yes, and says WHY separately. + patch(accessDb, 'activeGrant', async () => ({ user_id: guest.id, granted_by: leader.id })) + const resolved = await access.forumAccess(team.id, guest.id) + assert.equal(resolved.allowed, true) + assert.equal(resolved.viaGrant, true) + assert.equal(resolved.viaMembership, false) + + // …and path 4 still refuses, because an integration cannot verify that an + // unlinked, forum-granted account is a real game member. + assert.equal(await access.externalEligible(team.id, guest.id, 'discord'), false) +}) + +test('a leader is capped; staff are not, and are warned on the way past', async () => { + stubGrantWorld({ activeCount: 50 }) + patch(accessDb, 'insertGrant', async () => 1) + + const refused = await grants.grant({ team, actor: leader, username: 'mara' }) + assert.equal(refused.ok, false) + assert.equal(refused.status, 409) + + const allowed = await grants.grant({ team, actor: staff, username: 'mara' }) + assert.equal(allowed.ok, true) + assert.match(allowed.warning, /limit of 50/) +}) + +test('a leader may not revoke a staff-issued grant', async () => { + stubGrantWorld({ existing: { user_id: guest.id, username: 'mara', granted_by: staff.id } }) + patch(accessDb, 'revokeGrant', async () => true) + + const refused = await grants.revoke({ team, actor: leader, userId: guest.id }) + assert.equal(refused.ok, false) + assert.equal(refused.status, 403) + + // Staff may. This is what stops a leader undoing a moderation decision. + const allowed = await grants.revoke({ team, actor: staff, userId: guest.id }) + assert.equal(allowed.ok, true) +}) + +test('an account that has lost its staff role stops protecting the grants it made', async () => { + // Checked at REVOKE time against the issuer's current role, not against a flag + // stored when the grant was made — which is the behaviour an operator demoting + // someone expects. + const demoted = { id: 2, username: 'root', role: 'player' } + stubGrantWorld({ existing: { user_id: guest.id, username: 'mara', granted_by: demoted.id } }) + patch(usersDb, 'findById', async () => demoted) + patch(accessDb, 'revokeGrant', async () => true) + + const result = await grants.revoke({ team, actor: leader, userId: guest.id }) + assert.equal(result.ok, true) +}) + +test('a member who is also a grantee is listed as a member, not as a guest', async () => { + patch(accessDb, 'activeGrants', async () => [ + { user_id: 7, username: 'aldric', granted_username: 'root', granted_at: new Date(), reason: null }, + { user_id: 9, username: 'mara', granted_username: 'root', granted_at: new Date(), reason: null }, + ]) + patch(teamsDb, 'membersByTeam', async () => [{ member_key: '0x1', user_id: 7 }]) + + const guests = await grants.forumGuests(team.id) + assert.deepEqual(guests.map((g) => g.username), ['mara']) +}) + +// ── threads (§5.1, §5.3) ─────────────────────────────────────────────────── + +test('5a creates announcements and refuses discussion threads', async () => { + patch(forumDb, 'insertThread', async () => 1) + patch(forumDb, 'insertPost', async () => 1) + + const ok = await forum.createThread({ team, actor: leader, type: 'announcement', title: 'Raid', body: '
Hi
' }) + assert.equal(ok.ok, true) + + // The type exists in the enum from day one so 5b adds no migration — but + // nothing creates one yet. + const refused = await forum.createThread({ team, actor: leader, type: 'discussion', title: 'Chat', body: 'Hi
' }) + assert.equal(refused.ok, false) + assert.equal(refused.status, 400) +}) + +test('an announcement with only markup for a body is refused', async () => { + patch(forumDb, 'insertThread', async () => 1) + patch(forumDb, 'insertPost', async () => 1) + const refused = await forum.createThread({ team, actor: leader, type: 'announcement', title: 'x', body: '' }) + assert.equal(refused.ok, false) +}) + +test('moderation records WHICH authority was exercised', async () => { + const ledger = [] + patch(forumDb, 'threadById', async () => ({ id: 5, team_id: 1, status: 'visible' })) + patch(forumDb, 'setThreadFlags', async () => true) + patch(forumDb, 'insertModeration', async (row) => { ledger.push(row) }) + + await forum.moderateThread({ team, threadId: 5, action: 'lock', actor: leader, actorRole: 'leader' }) + await forum.moderateThread({ team, threadId: 5, action: 'hide', actor: staff, actorRole: 'staff' }) + + assert.deepEqual(ledger.map((r) => r.actorRole), ['leader', 'staff']) + assert.deepEqual(ledger.map((r) => r.action), ['lock', 'hide']) +}) + +test('a thread id from another Team reads as not found', async () => { + patch(forumDb, 'threadById', async () => ({ id: 5, team_id: 999, status: 'visible' })) + const result = await forum.getThread(1, 5, { canModerate: true }) + assert.equal(result, null) +}) + +test('a hidden thread is visible to whoever can unhide it, and to nobody else', async () => { + patch(forumDb, 'threadById', async () => ({ id: 5, team_id: 1, status: 'hidden', created_by: 7 })) + patch(forumDb, 'postsByThread', async () => []) + assert.equal(await forum.getThread(1, 5, { canModerate: false }), null) + assert.ok(await forum.getThread(1, 5, { canModerate: true })) +}) + +// ── uploads (§5.5.4) ─────────────────────────────────────────────────────── + +test('magic bytes decide the type, not the client’s Content-Type header', () => { + const png = Buffer.concat([ + Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), + Buffer.alloc(8), + ]) + assert.equal(uploads.sniff(png), 'image/png') + + // A player can send `image/png` with arbitrary bytes. Unrecognised is a + // rejection, never a fallback to what the header claimed. + assert.equal(uploads.sniff(Buffer.from(' ')), null) + assert.equal(uploads.sniff(Buffer.alloc(4)), null) // too short to judge +}) + +test('a RIFF container that is not WebP is not accepted as one', () => { + const wav = Buffer.concat([Buffer.from('RIFF'), Buffer.alloc(4), Buffer.from('WAVE'), Buffer.alloc(4)]) + assert.equal(uploads.sniff(wav), null) +}) diff --git a/server/test/teamRoutes.test.js b/server/test/teamRoutes.test.js index 4987ed2..7f9b68a 100644 --- a/server/test/teamRoutes.test.js +++ b/server/test/teamRoutes.test.js @@ -24,6 +24,10 @@ const moderation = require('../src/model/teams/teamModeration.model') const teamSync = require('../src/model/teams/teamSync.model') const activity = require('../src/model/activity/activity.model') const settings = require('../src/model/settings/settings.model') +const forumSettings = require('../src/model/teams/teamForumSettings.model') +const forum = require('../src/model/teams/teamForum.model') +const grants = require('../src/model/teams/teamGrants.model') +const access = require('../src/model/teams/teamAccess.model') const db = require('../src/utils/db') after(() => db.close()) @@ -302,3 +306,90 @@ test('an empty display name is routed to the CLEAR action, not published as blan assert.equal(action, 'display_name_override') }) }) + +// ── The forum's switch, at the route level (§5.5.1, phase 4) ─────────────── + +test('acceptance 2: with the forum off every forum route 404s, and nothing is touched', async () => { + signInAs(player) + patch(forumSettings, 'forumsEnabled', async () => false) + // Everything the forum would read or write if the guard failed. None of these + // may run: "off means guarded, never destroyed" is a claim about writes as much + // as about reads, and a guard that 404s AFTER loading the thread is one that + // still bumped a counter on the way. + let touched = false + const mark = () => { touched = true; return null } + patch(teamsDbModule, 'findBySlug', async () => { touched = true; return { id: 1, name: 'A' } }) + patch(forum, 'listThreads', async () => mark()) + patch(forum, 'getThread', async () => mark()) + patch(forum, 'createThread', async () => mark()) + patch(forum, 'moderateThread', async () => mark()) + + await withApp('/api/v1/player', playerRouter, async (app) => { + assert.equal((await get(app, '/api/v1/player/teams/a/forum/threads')).status, 404) + assert.equal((await get(app, '/api/v1/player/teams/a/forum/threads/1')).status, 404) + assert.equal((await post(app, '/api/v1/player/teams/a/forum/threads', { title: 'x', body: 'y' })).status, 404) + assert.equal((await post(app, '/api/v1/player/teams/a/forum/threads/1/moderate', { action: 'pin' })).status, 404) + }) + assert.equal(touched, false, 'a guarded route must not read or write the forum on its way to a 404') +}) + +test('with the forum ON, the same routes answer — the switch is the only difference', async () => { + signInAs(player) + patch(forumSettings, 'forumsEnabled', async () => true) + patch(forumSettings, 'imageMode', async () => 'disabled') + patch(teamsDbModule, 'findBySlug', async () => ({ id: 1, name: 'A' })) + patch(access, 'forumAccess', async () => ({ allowed: true, viaMembership: true, viaGrant: false, isLeader: false })) + patch(forum, 'listThreads', async () => []) + + await withApp('/api/v1/player', playerRouter, async (app) => { + const res = await get(app, '/api/v1/player/teams/a/forum/threads') + assert.equal(res.status, 200) + const body = await res.json() + assert.equal(body.canPost, false, 'an ordinary member does not get the announcement composer') + }) +}) + +test('a caller with no access gets 404, never 403', async () => { + // 403 says "this exists and you may not have it", which advertises a private + // room to someone outside it. In a forum the contents and the existence are the + // same secret. + signInAs(player) + patch(forumSettings, 'forumsEnabled', async () => true) + patch(teamsDbModule, 'findBySlug', async () => ({ id: 1, name: 'A' })) + patch(access, 'forumAccess', async () => ({ allowed: false, viaMembership: false, viaGrant: false, isLeader: false })) + + await withApp('/api/v1/player', playerRouter, async (app) => { + assert.equal((await get(app, '/api/v1/player/teams/a/forum/threads')).status, 404) + }) +}) + +test('the upload routes 404 in every image mode but uploads', async () => { + // The same guard at a second level, for the same reason. An upload control the + // client offers and the server refuses is worse than no control — which is why + // the mode is published, and why the SERVER is still what enforces it. + signInAs(player) + patch(forumSettings, 'forumsEnabled', async () => true) + patch(forumSettings, 'uploadsEnabled', async () => false) + patch(teamsDbModule, 'findBySlug', async () => ({ id: 1, name: 'A' })) + patch(access, 'forumAccess', async () => ({ allowed: true, viaMembership: true, viaGrant: false, isLeader: true })) + + await withApp('/api/v1/player', playerRouter, async (app) => { + assert.equal((await post(app, '/api/v1/player/teams/a/forum/uploads')).status, 404) + }) +}) + +test('the grant routes answer even while the forum is switched off', async () => { + // Deliberate (§5.5.1): a toggle-off revokes no grant and the rows stay + // authoritative, so the access list must stay manageable. What the switch + // guards is the forum's CONTENT, not its access list. + signInAs(player) + patch(forumSettings, 'forumsEnabled', async () => false) + patch(teamsDbModule, 'findBySlug', async () => ({ id: 1, name: 'A' })) + patch(grants, 'authorityFor', async () => ({ may: true, as: 'leader' })) + patch(grants, 'forumGuests', async () => []) + patch(grants, 'grantCap', async () => 50) + + await withApp('/api/v1/player', playerRouter, async (app) => { + assert.equal((await get(app, '/api/v1/player/teams/a/grants')).status, 200) + }) +})