feat(teams): phase 4 — the forum access model, announcements and the operator's controls #153

Merged
whitlocktech merged 6 commits from feature/teams-phase4-forum-access into edge 2026-08-18 14:18:11 +00:00
5 changed files with 1522 additions and 0 deletions
Showing only changes of commit 57286594e7 - Show all commits

View File

@@ -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",

View File

@@ -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"

View File

@@ -4352,6 +4352,112 @@
]
}
},
"/api/v1/admin/teams/forum/settings": {
"get": {
"tags": [
"Admin · Teams"
],
"summary": "The forum switch, the image policy, and the acknowledgements 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 Teams forum moderation ledger",
"description": "Append-only, and deliberately separate from the sites mod_actions/appeals pair (§5.3): that one is Discord-sanction-shaped and bot-owned, and routing a guild leader locking a thread through it would make ordinary housekeeping an appealable sanction. `actorRole` records which authority was exercised — a leaders action appears only here, a staffers appears here AND in activity_log. Answers whether or not the forum is switched on.",
"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 forums 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 5s discussion threads add no migration. The body is sanitised with the FORUMs own profile, in which `img` is never allowed — an author writes a URL and core decides at render time whether it becomes a picture.",
"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 <img> beneath each link that names an image. The stored HTML is identical in all three — flipping the policy back to disabled un-renders every image on every existing post with no data migration.",
"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 Teams own append-only moderation ledger recording WHICH authority was exercised; a staff-exercised one additionally writes activity_log, so the sites staff-accountability trail sees it while a leaders ordinary housekeeping stays out of it. Deliberately not routed through the sites mod_actions/appeals pair, which is Discord-sanction-shaped.",
"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 paths 8 MB cap, mimetype allowlist and random filename, this one assumes a hostile uploader: the leading bytes are sniffed and a mismatch with the declared type is rejected (a clients Content-Type header is a claim, not a fact), a rolling per-account byte quota applies, and every accepted file gets an attribution row naming who uploaded it.",
"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 Teams 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 issuers 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 grantees 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": [

View File

@@ -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 forums 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('<p>Banner: https://example.com/banner.png</p>')
// The author wrote a URL and it was stored as a LINK. No <img> 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('<img'))
assert.match(stored, /<a href="https:\/\/example\.com\/banner\.png"/)
const disabled = renderForumBody(stored, 'disabled')
const remote = renderForumBody(stored, 'remote')
assert.equal(disabled, stored) // byte-for-byte
assert.match(remote, /<img src="https:\/\/example\.com\/banner\.png"/)
assert.match(remote, /loading="lazy"/)
assert.match(remote, /referrerpolicy="no-referrer"/)
// The link survives in both. A blocked or dead image degrades to the URL the
// author actually wrote.
assert.ok(remote.includes('<a href="https://example.com/banner.png"'))
})
test('an author cannot write an img tag, or smuggle attributes through one', () => {
const stored = cleanForumBody(
'<p><img src="https://evil.test/x.png" onerror="alert(1)" width="99999" srcset="y"></p>',
)
assert.ok(!stored.includes('<img'))
assert.ok(!stored.includes('onerror'))
assert.ok(!stored.includes('srcset'))
// And it stays absent when the policy is at its most permissive: the only code
// that can emit an <img> is core's renderer.
assert.ok(!renderForumBody(stored, 'uploads').includes('<img'))
})
test('http URLs and non-image URLs stay plain links', () => {
// 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('<p>http://x.test/a.png</p>'), 'remote')
assert.ok(!httpUrl.includes('<img'))
const notAnImage = renderForumBody(cleanForumBody('<p>https://x.test/a.exe</p>'), 'remote')
assert.ok(!notAnImage.includes('<img'))
})
test('a URL inside code or pre is shown, not offered', () => {
const stored = cleanForumBody('<pre>https://example.com/a.png</pre>')
assert.ok(!stored.includes('<a href'))
})
test('every link ships with a safe rel, including one the author wrote', () => {
const stored = cleanForumBody('<a href="https://x.test/" rel="me">x</a>')
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: '<p>Hi</p>' })
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: '<p>Hi</p>' })
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: '<p></p>' })
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 clients 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('<?php echo 1; ?> ')), 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)
})

View File

@@ -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)
})
})