test(teams): the four acceptance criteria, and regenerate the API artifacts
All checks were successful
PR Checks / bot-install (pull_request) Successful in 17s
PR Checks / client-build (pull_request) Successful in 27s
PR Checks / server-tests (pull_request) Successful in 32s

Four tests are named "acceptance" and are Phase 4's criteria verbatim. Each names
a property the code around it can lose without any screen looking different:

1. A granted, unlinked account reads the forum, is absent from the member rows, and
   is still refused external-platform eligibility. The membership projection is
   asserted byte-identical across a grant, which is what "non-contamination" means
   in practice.
2. With the switch off every forum route 404s AND nothing is read or written on the
   way there — a guard that 404s after loading the thread is one that still bumped
   a counter.
3. The stored HTML is byte-identical between `disabled` and `remote`; only the
   rendered output differs. That is the property the renderer-owned design exists
   to give, and it is what makes flipping the policy back a no-op rather than a
   migration.
4. Selecting `uploads` without a matching acknowledgement is refused server-side,
   with the admin checkbox bypassed.

Plus the ones that are not criteria but are the same kind of claim: an author
cannot smuggle an <img> or its attributes through in any mode, http and non-image
URLs stay plain links, a leader cannot revoke a staff-issued grant, a demoted
account stops protecting the grants it made, moderation records which authority was
exercised, and a RIFF container that is not WebP is not accepted as one.

Twelve new routes in the manifest, all annotated and in the OpenAPI spec.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-08-18 07:24:24 -05:00
parent cbb7339a3a
commit 57286594e7
5 changed files with 1522 additions and 0 deletions

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": [