feat(teams): Teams as a platform primitive — MODULE_API 1.6.0 (Teams cutover 4/6) #161
@@ -774,6 +774,17 @@
|
|||||||
"validate"
|
"validate"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/v1/admin/teams/:id/forum/moderation",
|
||||||
|
"handlers": 3,
|
||||||
|
"gates": [
|
||||||
|
"noindex",
|
||||||
|
"requireAuth",
|
||||||
|
"middleware",
|
||||||
|
"validate"
|
||||||
|
]
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"method": "GET",
|
"method": "GET",
|
||||||
"path": "/api/v1/admin/teams/:id/grants",
|
"path": "/api/v1/admin/teams/:id/grants",
|
||||||
@@ -829,6 +840,26 @@
|
|||||||
"validate"
|
"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",
|
"method": "GET",
|
||||||
"path": "/api/v1/admin/teams/requests",
|
"path": "/api/v1/admin/teams/requests",
|
||||||
@@ -1656,6 +1687,100 @@
|
|||||||
"requireAuth"
|
"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",
|
"method": "POST",
|
||||||
"path": "/api/v1/public/contact",
|
"path": "/api/v1/public/contact",
|
||||||
|
|||||||
@@ -309,6 +309,10 @@
|
|||||||
"method": "POST",
|
"method": "POST",
|
||||||
"path": "/api/v1/admin/teams/:id/display-name"
|
"path": "/api/v1/admin/teams/:id/display-name"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/v1/admin/teams/:id/forum/moderation"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"method": "GET",
|
"method": "GET",
|
||||||
"path": "/api/v1/admin/teams/:id/grants"
|
"path": "/api/v1/admin/teams/:id/grants"
|
||||||
@@ -329,6 +333,14 @@
|
|||||||
"method": "POST",
|
"method": "POST",
|
||||||
"path": "/api/v1/admin/teams/:id/unhide"
|
"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",
|
"method": "GET",
|
||||||
"path": "/api/v1/admin/teams/requests"
|
"path": "/api/v1/admin/teams/requests"
|
||||||
@@ -665,6 +677,42 @@
|
|||||||
"method": "GET",
|
"method": "GET",
|
||||||
"path": "/api/v1/player/teams/:slug/access"
|
"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",
|
"method": "POST",
|
||||||
"path": "/api/v1/public/contact"
|
"path": "/api/v1/public/contact"
|
||||||
|
|||||||
@@ -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": {
|
"/api/v1/admin/teams/requests": {
|
||||||
"get": {
|
"get": {
|
||||||
"tags": [
|
"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": {
|
"/api/v1/admin/teams/{id}/grants": {
|
||||||
"get": {
|
"get": {
|
||||||
"tags": [
|
"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 <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 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": {
|
"/api/v1/public/contact": {
|
||||||
"post": {
|
"post": {
|
||||||
"tags": [
|
"tags": [
|
||||||
|
|||||||
332
server/test/teamForum.test.js
Normal file
332
server/test/teamForum.test.js
Normal 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 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('<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 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('<?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)
|
||||||
|
})
|
||||||
@@ -24,6 +24,10 @@ const moderation = require('../src/model/teams/teamModeration.model')
|
|||||||
const teamSync = require('../src/model/teams/teamSync.model')
|
const teamSync = require('../src/model/teams/teamSync.model')
|
||||||
const activity = require('../src/model/activity/activity.model')
|
const activity = require('../src/model/activity/activity.model')
|
||||||
const settings = require('../src/model/settings/settings.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')
|
const db = require('../src/utils/db')
|
||||||
|
|
||||||
after(() => db.close())
|
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')
|
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)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user