/api/v1/forum
Sections, threads, posts and moderation. Defined in
crates/rest/src/forum.rs.
Sanctions — warnings and mutes — are a page of their own: sanctions.
Referring to a section or a thread
:section_ref and :thread_ref each accept either a UUID or a key. A key
is a short handle: ^[_a-z][_a-z0-9]{0,31}$ for a section, for instance
fr_main or drpg_main. The server tries the UUID first and falls back to
the key.
Posts are addressed by UUID only.
Who may do what
Authority is resolved once per request from three things: the roles the
account holds (Administrator, GlobalModerator, Moderator of one
section), the mutes in force against it, and the ceiling its credentials put
on it.
That ceiling matters to applications. A browser session carries the full authority of the user; an OAuth access token is capped by its scopes:
| Credential | May |
|---|---|
| No credentials, or a client-only token | Read. |
Token without forum:write | Read. A write is refused for a missing scope. |
Token with forum:write | Post, as the user and with none of the user's roles. |
Token with forum:moderate | Post, and use the user's roles. |
| Browser session or HTTP Basic | Everything the account may do. |
An administrator who signs into a game does not carry administrator powers into it: the flag lives on the user row and no scope grants it.
Rather than re-deriving any of this, a client reads the self block that
comes with every section, thread and post: can_post, can_lock, can_pin,
can_move, can_delete, can_report, can_edit, can_create_thread. Draw
a button if and only if the matching flag is set.
A section's self block also carries grammar: the Marktwin an actor may
write here, as the server will parse it. An editor should offer exactly that
and no more — markup the server strips on save is markup the author sees and
the reader never does.
Paging
offset and limit are accepted by the listing routes. When limit is
absent, the server uses the value published by
config: threads_per_page (20) for threads, sanctions,
reports and the moderation log, posts_per_page (10) for posts and revisions.
The moderation log, the report queue and the sanction listing each clamp
limit to 100.
Sections
GET /sections
The section tree, as a listing of section metadata. Public.
GET /api/v1/forum/sections
{
"offset": 0,
"limit": 20,
"count": 1,
"items": [
{
"type": "ForumSection",
"id": "d6e3a4ae-3b1c-4f3e-9a6b-2c9bdc6b7e10",
"key": "fr_main",
"display_name": "Général",
"ctime": "2021-01-15T14:17:14.015Z",
"locale": "fr-FR",
"parent": null,
"threads": {"count": 37},
"self": {
"roles": [],
"grammar": {"admin": false, "code": true, "depth": 4},
"unread_threads": 0,
"can_create_thread": false
}
}
]
}
threads here is a bare count, not a page. self.unread_threads is 0 for a
guest. The grammar object is abridged above; it mirrors the Marktwin grammar
field for field.
Errors: 500{"error": "internal error"}.
POST /sections
Creates the section, or updates it in place when one already exists under the same key.
Internal: requires the Etwin-Internal-Auth header, not a user
credential. It is how [seed.forum_section] is applied at boot.
| Field | Type | Meaning |
|---|---|---|
key | string | Stable handle. |
display_name | string | Name shown to members. |
locale | string or null | Locale of the section. |
parent | string or absent | Key of the parent section. |
order | integer | Sort order among its siblings. Defaults to 0. |
Answers with the full section.
| Status | Body | When |
|---|---|---|
| 401 | {"error": "missing internal authentication header"} | No header. |
| 403 | {"error": "invalid internal authentication key"} | Wrong key. |
| 422 | {"error": "parent section not found"} | parent names nothing. |
| 500 | {"error": "internal error"} | — |
GET /sections/:section_ref
One section with a page of its threads, its children and its role grants.
| Query | Type | Default |
|---|---|---|
offset | integer | 0 |
limit | integer | threads_per_page |
GET /api/v1/forum/sections/fr_main?offset=0&limit=2
{
"type": "ForumSection",
"id": "d6e3a4ae-3b1c-4f3e-9a6b-2c9bdc6b7e10",
"key": "fr_main",
"display_name": "Général",
"ctime": "2021-01-15T14:17:14.015Z",
"locale": "fr-FR",
"parent": null,
"threads": {
"offset": 0,
"limit": 2,
"count": 37,
"items": [
{
"type": "ForumThread",
"id": "0f3b9b7e-2c1f-4a49-9a1e-0b7d3f9c0a11",
"key": null,
"title": "Bienvenue",
"ctime": "2021-01-15T14:17:14.015Z",
"is_pinned": true,
"is_locked": false,
"posts": {"count": 12},
"last_post": {
"type": "ForumPost",
"id": "6c3a1c58-1f3a-4a1e-9c2b-8d0f5a7b1c22",
"ctime": "2021-02-01T09:00:00.000Z",
"author": {
"type": "UserForumActor",
"user": {
"type": "User",
"id": "9f310484-963b-446b-af69-797feec6813f",
"display_name": {"current": {"value": "Demurgos"}}
}
}
},
"has_admin_announcement": false,
"self": {"is_unread": false}
}
]
},
"children": [],
"role_grants": [],
"self": {
"roles": [],
"grammar": {"admin": false, "code": true, "depth": 4},
"unread_threads": 0,
"can_create_thread": false
}
}
An author is a tagged actor, not a plain user: UserForumActor (a member,
with an optional role), RoleForumActor (a moderation act attributed to a
role) or ClientForumActor (an OAuth client posting on its own behalf).
has_admin_announcement is derived from the [admin] blocks in the posts as
they read now, so an announcement that is edited away stops being advertised.
| Status | Body | When |
|---|---|---|
| 404 | {"error": "section not found"} | — |
| 500 | {"error": "internal error"} | — |
POST /sections/:section_ref
Opens a thread in the section. Requires credentials that resolve, and the right to post here.
| Field | Type | Meaning |
|---|---|---|
title | string | Thread title. |
body | string | Marktwin source of the first post. |
Answers with the created thread.
| Status | Body | When |
|---|---|---|
| 400 | {"error": "section holds no threads"} | The section is a category. |
| 400 | {"error": "failed to parse provided body"} | The Marktwin is invalid. |
| 400 | {"error": "failed to render provided body"} | — |
| 401 | — | Credentials were supplied but could not be resolved. |
| 403 | {"error": "current actor does not have the permission to create a thread in this section"} | Guest, missing scope, mute or insufficient role. |
| 404 | {"error": "section not found"} | — |
Roles
POST /sections/:section_ref/role_grants, DELETE /sections/:section_ref/role_grants
Adds or removes a moderator of one section. Body is a user reference:
POST /api/v1/forum/sections/fr_main/role_grants
Content-Type: application/json
{"type": "User", "id": "9f310484-963b-446b-af69-797feec6813f"}
Answers with the section.
| Status | Body | When |
|---|---|---|
| 403 | {"error": "current actor does not have the permission to add moderators to this section"} | — |
| 404 | {"error": "section not found"} | — |
| 422 | {"error": "target grantee user not found"} | — |
POST /role_grants, DELETE /role_grants
Grants or revokes any forum role, scoped or site-wide. Section-less on
purpose: Administrator and GlobalModerator are held over no section at
all, so there is no /sections/:section_ref under which they could be
addressed.
| Field | Type | Meaning |
|---|---|---|
user | user reference | Who the role is for. |
role | string | "Administrator", "GlobalModerator" or "Moderator". |
section | section reference or absent | Required for Moderator, forbidden for the other two. |
DELETE /api/v1/forum/role_grants
Content-Type: application/json
{
"user": {"type": "User", "id": "0f3b9b7e-2c1f-4a49-9a1e-0b7d3f9c0a11"},
"role": "Moderator",
"section": {"type": "ForumSection", "key": "fr_main"}
}
Both answer 200 with an empty body.
| Status | Body | When |
|---|---|---|
| 400 | {"error": "role scope mismatch: `Moderator` needs a section, and a global role must not have one"} | — |
| 401 | — | Credentials were supplied but could not be resolved. |
| 403 | {"error": "current actor does not have the permission to grant this role"} | Checked before the target is looked up, so a guest is never told whether the user exists. |
| 404 | {"error": "section not found"} or {"error": "target grantee user not found"} | — |
Threads
GET /threads/:thread_ref
One thread with a page of its posts. Public.
| Query | Type | Default |
|---|---|---|
offset | integer | 0 |
limit | integer | posts_per_page |
{
"type": "ForumThread",
"id": "0f3b9b7e-2c1f-4a49-9a1e-0b7d3f9c0a11",
"key": null,
"title": "Bienvenue",
"ctime": "2021-01-15T14:17:14.015Z",
"section": {"type": "ForumSection", "id": "d6e3a4ae-3b1c-4f3e-9a6b-2c9bdc6b7e10", "key": "fr_main", "display_name": "Général", "ctime": "2021-01-15T14:17:14.015Z", "locale": "fr-FR", "parent": null, "threads": {"count": 37}, "self": {"roles": [], "grammar": {}, "unread_threads": 0, "can_create_thread": false}},
"posts": {
"offset": 0,
"limit": 10,
"count": 12,
"items": [
{
"type": "ForumPost",
"id": "6c3a1c58-1f3a-4a1e-9c2b-8d0f5a7b1c22",
"ctime": "2021-01-15T14:17:14.015Z",
"author": {
"type": "UserForumActor",
"user": {"type": "User", "id": "9f310484-963b-446b-af69-797feec6813f", "display_name": {"current": {"value": "Demurgos"}}}
},
"revisions": {
"count": 1,
"last": {
"type": "ForumPostRevision",
"id": "ab0f5f1e-3cf0-4e1b-9d11-2a0c8e5f4b33",
"time": "2021-01-15T14:17:14.015Z",
"author": {"type": "UserForumActor", "user": {"type": "User", "id": "9f310484-963b-446b-af69-797feec6813f", "display_name": {"current": {"value": "Demurgos"}}}},
"content": {"html": "<p>Bonjour</p>"},
"moderation": null,
"comment": null
}
},
"self": {"can_edit": false, "can_delete": false, "can_report": false}
}
]
},
"is_pinned": true,
"is_locked": false,
"self": {
"can_post": false,
"can_lock": false,
"can_pin": false,
"can_move": false,
"can_delete": false,
"can_report": false
}
}
A revision body carries html and, normally, no marktwin: the rendered
fragment is what a reader needs, and one canonical rendering path means a
client cannot quietly render the source its own way. A body that is present
but has no marktwin is normal, not truncated. The source is served by
GET /posts/:post_ref/source alone.
moderation is the moderator's note replacing a hidden body; it is null on
an ordinary revision.
Errors: 404{"error": "thread not found"}, 500 internal.
POST /threads/:thread_ref
Replies to the thread.
| Field | Type | Meaning |
|---|---|---|
body | string | Marktwin source. |
Answers with the created post.
| Status | Body | When |
|---|---|---|
| 400 | {"error": "failed to parse provided body"} | — |
| 401 | — | Credentials were supplied but could not be resolved. |
| 403 | {"error": "current actor does not have the permission to create a post in this thread"} | Guest, missing forum:write, mute, or the thread is locked. |
| 404 | {"error": "thread not found"} | — |
PATCH /threads/:thread_ref
Renames, pins or locks. An absent field is left alone; there is no "unset".
| Field | Type |
|---|---|
title | string |
is_pinned | boolean |
is_locked | boolean |
PATCH /api/v1/forum/threads/0f3b9b7e-2c1f-4a49-9a1e-0b7d3f9c0a11
Content-Type: application/json
{"is_pinned": true}
| Status | Body | When |
|---|---|---|
| 403 | {"error": "current actor does not have the permission to moderate this thread"} | — |
| 404 | {"error": "thread not found"} | — |
| 409 | {"error": "thread is already deleted"} | The request disagrees with the current state. |
POST /threads/:thread_ref/section
Moves the thread. Its own sub-resource rather than a field of the PATCH: it
is the only moderation command needing rights on two sections at once, and the
only one that can fail because the destination is missing.
| Field | Type |
|---|---|
section | section reference |
| Status | Body | When |
|---|---|---|
| 400 | {"error": "destination section holds no threads"} | The destination is a category. |
| 403 | {"error": "current actor does not have the permission to move this thread"} | — |
| 404 | {"error": "thread not found"} or {"error": "destination section not found"} | — |
| 409 | {"error": "thread is already deleted"} | — |
DELETE /threads/:thread_ref
Deletes the thread. The body is optional — plenty of clients cannot send one
on a DELETE, and it only carries a note:
| Field | Type | Meaning |
|---|---|---|
comment | string | Kept in the moderation log, not shown to the author. |
| Status | Body | When |
|---|---|---|
| 403 | {"error": "current actor does not have the permission to delete this thread"} | — |
| 404 | {"error": "thread not found"} | — |
| 409 | {"error": "thread is already deleted"} | — |
POST /threads/:thread_ref/restore
Undoes a deletion. Takes no body.
| Status | Body | When |
|---|---|---|
| 403 | {"error": "current actor does not have the permission to restore this thread"} | — |
| 404 | {"error": "thread not found"} | — |
| 409 | {"error": "thread is not deleted"} | — |
POST /threads/:thread_ref/read
Records that the current member has been shown every post up to up_to_post.
| Field | Type | Meaning |
|---|---|---|
up_to_post | UUID | The most recent post the client declares it has displayed. |
A post id and not an instant: the server resolves the time itself, so a client cannot claim to be current on messages that do not exist, nor mark a thread read up to tomorrow.
Reading is recorded by this explicit write and never as a side effect of the
GET: a link prefetch, a crawler or a server-side render must not be able to
mark a thread read on the member's behalf.
Answers with the thread's self block, {"is_unread": false}, so the caller
can redraw the row it just changed.
| Status | Body | When |
|---|---|---|
| 401 | {"error": "authentication required"} | A guest has no reading history. |
| 404 | {"error": "thread not found"} | — |
| 422 | {"error": "post does not belong to this thread"} | The client's own state is wrong. |
POST /read_floor
Clears every unread thread at once by raising the member's floor to now. Takes
no meaningful body; send {}.
Not under /sections: the floor is one value for the whole forum.
Answers with the refreshed section listing — the counters the caller has to redraw — rather than making it ask for them again.
| Status | Body | When |
|---|---|---|
| 401 | {"error": "authentication required"} | — |
POST /threads/:thread_ref/reports
Reports the thread to the moderation team. The body and the errors are the same as for a post; both are described under Reports, below.
Posts
GET /posts/:post_ref
One post with a page of its revisions and the thread it belongs to.
| Query | Type | Default |
|---|---|---|
offset | integer | 0 |
limit | integer | posts_per_page |
| Status | Body | When |
|---|---|---|
| 404 | {"error": "post not found"} | — |
| 500 | {"error": "internal error"} | — |
GET /posts/:post_ref/source
The same object, with the marktwin field of every revision body present.
A separate path rather than a query flag, so that "may read the source" is a permission on a resource instead of a modifier on a public read. Reserved to the author and to moderators — a 403 here also answers "may I edit this?" without a second endpoint to ask it.
| Status | Body | When |
|---|---|---|
| 403 | {"error": "current actor does not have the permission to read the source of this post"} | — |
| 404 | {"error": "post not found"} | — |
PATCH /posts/:post_ref
Writes a new revision.
| Field | Type | Meaning |
|---|---|---|
last_revision_id | UUID | The revision the caller edited from. Rejected if it is no longer the last one. |
content | string, null or absent | New body. null clears it, absent leaves it. |
moderation | string, null or absent | Moderator's note. Same convention. |
comment | string or null | Why. Must be present, may be null. |
An author may only correct the last post of a thread, and only while no moderator has rewritten it; a moderator may rewrite anyone's.
| Status | Body | When |
|---|---|---|
| 400 | {"error": "failed to parse provided body"} | — |
| 403 | {"error": "current actor does not have the permission to update this post"} | Not the author, superseded by a reply, or already moderated. |
| 404 | {"error": "post not found"} | — |
DELETE /posts/:post_ref
Hides the body behind a moderation note. Not a row deletion: it writes a revision whose body is null, so the history stays and the act stays attributable to the moderator who performed it.
| Field | Type | Meaning |
|---|---|---|
last_revision_id | UUID | As for the PATCH. |
comment | string or absent | Moderation note. |
Moderators only; an author who wants their message gone asks one, so that the removal is attributable.
| Status | Body | When |
|---|---|---|
| 403 | {"error": "current actor does not have the permission to hide this post"} | Every failure of this command is reported this way. |
Reports
A report is filed against the resource it is posted to, and read back from a queue of its own.
POST /posts/:post_ref/reports and POST /threads/:thread_ref/reports
| Field | Type | Meaning |
|---|---|---|
reason | string | "Spam", "Harassment", "NsfwContent", "OffTopic", "Illegal" or "Other". |
body | string or absent | Free text from the reporter. |
A short closed list rather than free text: it is what the queue is sorted and
triaged by, and a reporter who has to pick one writes a more useful body
than one given a blank box.
The target is not repeated in the body — it is the resource the request is posted to.
| Status | Body | When |
|---|---|---|
| 403 | {"error": "current actor does not have the permission to report this content"} | — |
| 404 | {"error": "reported post or thread not found"} | — |
| 409 | {"error": "already reported by this user and not yet resolved"} | — |
GET /reports
The moderation queue. Moderators only.
| Query | Type | Meaning |
|---|---|---|
status | string | "Pending", "Accepted" or "Rejected". |
section | UUID | Section identifier. A key is not resolved here. |
offset | integer | Default 0. |
limit | integer | Default threads_per_page, clamped to 100. |
{
"offset": 0,
"limit": 20,
"count": 1,
"items": [
{
"type": "ForumReport",
"id": "3b1a9e0c-7a5f-4c6d-8e2b-1f0a9c3d5e70",
"ctime": "2021-02-01T09:00:00.000Z",
"reporter": {"type": "User", "id": "28dbb0bf-0fdc-40fe-ae5a-dde193f9fea8", "display_name": {"current": {"value": "Alice"}}},
"target": {"type": "ForumPost", "id": "6c3a1c58-1f3a-4a1e-9c2b-8d0f5a7b1c22"},
"reason": "Spam",
"body": "links to a shop",
"status": "Pending",
"resolved_at": null,
"resolved_by": null,
"resolution_note": null,
"context": {
"type": "ForumReportContext",
"thread": {"type": "ForumThread", "id": "0f3b9b7e-2c1f-4a49-9a1e-0b7d3f9c0a11"},
"thread_title": "Bienvenue",
"section": {"type": "ForumSection", "id": "d6e3a4ae-3b1c-4f3e-9a6b-2c9bdc6b7e10"},
"author": {"type": "User", "id": "9f310484-963b-446b-af69-797feec6813f", "display_name": {"current": {"value": "Demurgos"}}},
"html": "<p>…</p>"
}
}
]
}
target is exactly one of a post or a thread, told apart by its type.
context is the reported content resolved — whose words these are, where they
were said, what they said — and is absent when the target has since been
deleted outright: a report outlives its target, and the queue still has to
show the line so it can be closed.
The identity of the reporter is moderator-only information: a forum where the reported can see who reported them is a forum where nobody reports anything.
| Status | Body | When |
|---|---|---|
| 403 | {"error": "current actor does not have the permission to read this report queue"} | — |
GET /reports/:report_id
One report, same shape.
| Status | Body | When |
|---|---|---|
| 403 | {"error": "current actor does not have the permission to read this report"} | — |
| 404 | {"error": "report not found"} | Also when the target is gone, so the read never advertises that the id exists. |
POST /reports/:report_id/resolution
Closes a report. A sub-resource rather than a PATCH of the report: the
status is the only mutable part, it moves once and never back, and the note
belongs to that one transition.
| Field | Type | Meaning |
|---|---|---|
status | string | "Accepted" or "Rejected". |
note | string or absent | What the moderator concluded. |
"Pending" is the state a report starts in, not one it can be moved to.
| Status | Body | When |
|---|---|---|
| 400 | {"error": "a report cannot be resolved to `Pending`"} | — |
| 403 | {"error": "current actor does not have the permission to resolve this report"} | — |
| 404 | {"error": "report not found"} or {"error": "reported post or thread not found"} | — |
| 409 | {"error": "report is already resolved"} | Somebody else closed it first. |
GET /moderation_log
What moderators have done. Without a section filter the log spans the whole
site, which takes an administrator; with one, a moderator of that section may
read it.
| Query | Type | Meaning |
|---|---|---|
section | UUID | Section identifier. A key is not resolved here. |
actor | UUID | Only the acts of this moderator. |
offset | integer | Default 0. |
limit | integer | Default threads_per_page, clamped to 100. |
{
"offset": 0,
"limit": 20,
"count": 1,
"items": [
{
"type": "ForumModerationEvent",
"id": "7d2c6f90-4b3a-4c1e-9f8d-0a1b2c3d4e5f",
"ctime": "2021-02-01T09:05:00.000Z",
"actor": {"type": "User", "id": "9f310484-963b-446b-af69-797feec6813f", "display_name": {"current": {"value": "Demurgos"}}},
"action": "LockThread",
"section": "d6e3a4ae-3b1c-4f3e-9a6b-2c9bdc6b7e10",
"thread": "0f3b9b7e-2c1f-4a49-9a1e-0b7d3f9c0a11",
"post": null,
"target_user": null,
"comment": null,
"data": {"is_locked": true}
}
]
}
action is one of LockThread, UnlockThread, PinThread, UnpinThread,
MoveThread, RenameThread, DeleteThread, RestoreThread, DeletePost,
EditPost, GrantRole, RevokeRole, CreateSanction, DeleteSanction,
ResolveReport.
section, thread and post are bare identifiers rather than references:
the log is read as a list, and resolving every target of every line would cost
one query per line for something the reader mostly uses as a link.
data carries only the fields the action sets — a lock carries is_locked, a
move carries from_section and to_section, a rename carries title.
| Status | Body | When |
|---|---|---|
| 403 | {"error": "current actor does not have the permission to read this moderation log"} | — |
| 404 | {"error": "section not found"} | — |