Eternaltwin

Home | /api | v1

/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:

CredentialMay
No credentials, or a client-only tokenRead.
Token without forum:writeRead. A write is refused for a missing scope.
Token with forum:writePost, as the user and with none of the user's roles.
Token with forum:moderatePost, and use the user's roles.
Browser session or HTTP BasicEverything 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.

FieldTypeMeaning
keystringStable handle.
display_namestringName shown to members.
localestring or nullLocale of the section.
parentstring or absentKey of the parent section.
orderintegerSort order among its siblings. Defaults to 0.

Answers with the full section.

StatusBodyWhen
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.

QueryTypeDefault
offsetinteger0
limitintegerthreads_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.

StatusBodyWhen
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.

FieldTypeMeaning
titlestringThread title.
bodystringMarktwin source of the first post.

Answers with the created thread.

StatusBodyWhen
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.

StatusBodyWhen
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.

FieldTypeMeaning
useruser referenceWho the role is for.
rolestring"Administrator", "GlobalModerator" or "Moderator".
sectionsection reference or absentRequired 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.

StatusBodyWhen
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.

QueryTypeDefault
offsetinteger0
limitintegerposts_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.

FieldTypeMeaning
bodystringMarktwin source.

Answers with the created post.

StatusBodyWhen
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".

FieldType
titlestring
is_pinnedboolean
is_lockedboolean
PATCH /api/v1/forum/threads/0f3b9b7e-2c1f-4a49-9a1e-0b7d3f9c0a11
Content-Type: application/json

{"is_pinned": true}
StatusBodyWhen
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.

FieldType
sectionsection reference
StatusBodyWhen
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:

FieldTypeMeaning
commentstringKept in the moderation log, not shown to the author.
StatusBodyWhen
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.

StatusBodyWhen
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.

FieldTypeMeaning
up_to_postUUIDThe 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.

StatusBodyWhen
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.

StatusBodyWhen
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.

QueryTypeDefault
offsetinteger0
limitintegerposts_per_page
StatusBodyWhen
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.

StatusBodyWhen
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.

FieldTypeMeaning
last_revision_idUUIDThe revision the caller edited from. Rejected if it is no longer the last one.
contentstring, null or absentNew body. null clears it, absent leaves it.
moderationstring, null or absentModerator's note. Same convention.
commentstring or nullWhy. 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.

StatusBodyWhen
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.

FieldTypeMeaning
last_revision_idUUIDAs for the PATCH.
commentstring or absentModeration note.

Moderators only; an author who wants their message gone asks one, so that the removal is attributable.

StatusBodyWhen
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

FieldTypeMeaning
reasonstring"Spam", "Harassment", "NsfwContent", "OffTopic", "Illegal" or "Other".
bodystring or absentFree 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.

StatusBodyWhen
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.

QueryTypeMeaning
statusstring"Pending", "Accepted" or "Rejected".
sectionUUIDSection identifier. A key is not resolved here.
offsetintegerDefault 0.
limitintegerDefault 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.

StatusBodyWhen
403{"error": "current actor does not have the permission to read this report queue"}—

GET /reports/:report_id

One report, same shape.

StatusBodyWhen
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.

FieldTypeMeaning
statusstring"Accepted" or "Rejected".
notestring or absentWhat the moderator concluded.

"Pending" is the state a report starts in, not one it can be moved to.

StatusBodyWhen
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.

QueryTypeMeaning
sectionUUIDSection identifier. A key is not resolved here.
actorUUIDOnly the acts of this moderator.
offsetintegerDefault 0.
limitintegerDefault 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.

StatusBodyWhen
403{"error": "current actor does not have the permission to read this moderation log"}—
404{"error": "section not found"}—