The forum from an application
Eternaltwin runs one forum for every game, and an application may take part in it on behalf of the player who authorized it. A game can show its own community's threads on its own pages, let a player reply without leaving the game, or relay an in-game event as a post — always as that player, never as the game itself.
This page assumes the application is already registered and can obtain an access token: see Eternaltwin for OAuth first.
Ask for a scope
Reading and writing the forum are separate permissions, requested as OAuth scopes:
| Scope | What it allows |
|---|---|
base | Nothing on the forum beyond what an anonymous visitor already reads. Granted by default. |
forum:read | Reading the forum as the user, including what is theirs alone (unread counts). |
forum:write | Opening a thread, replying, editing their own message, reporting. |
forum:moderate | The moderation commands, where the user is already a moderator. |
Asking for one implies the ones below it: scope=forum:write also grants forum:read and base.
There is no need to list them, and listing them changes nothing.
Ask for forum:read anyway when reading is all the application does. No read endpoint enforces it
today — the forum is readable by anyone, signed in or not — but it is the scope the consent screen
shows the user, and the one that will be checked when a section is closed to the public.
An application may only ask for what it was allowed at registration. The allowance is the
allowed_scopes key of its section in eternaltwin.toml:
[seed.app.dinorpg] display_name = "DinoRPG" uri = "http://dinorpg.localhost/" oauth_callback = "http://dinorpg.localhost/oauth/callback" secret = "dev_secret" allowed_scopes = "forum:write"
Omitting the key leaves the current allowance alone rather than resetting it, so an application
whose allowance was widened by an administrator does not lose it at the next restart. An
application that was never given one may only request base, and asking for more is refused at
/oauth/authorize.
The scope then travels on the authorization request:
https://eternaltwin.org/oauth/authorize ?response_type=code &client_id=dinorpg@clients &redirect_uri=https%3A%2F%2Fdinorpg.example%2Foauth%2Fcallback &scope=forum%3Awrite &state=<signed JWT>
What the user sees
Eternaltwin asks the user before issuing a code: the consent screen names the application and what
it would be able to do. The answer is remembered, so a returning user is not asked again — unless
the application comes back asking for more than was approved, which asks again for the wider
set. A refusal redirects to the application's callback with the RFC 6749 access_denied error.
Ask for the narrowest scope that does the job. An application that only displays threads and asks
for forum:moderate is asking a moderator for the right to act in their name, and will be refused
by the people most able to tell the difference.
Identify the user
Everything below is sent with the access token:
GET /api/v1/auth/self Authorization: Bearer 5f6613eb-880f-4b01-8e71-96b644e4584f
{
"type": "AccessToken",
"scope": "Default",
"client": { "type": "OauthClient", "id": "…", "key": "dinorpg@clients", "display_name": "DinoRPG" },
"user": { "type": "User", "id": "0d8d5067-…", "display_name": { "current": { "value": "Elseabora" } } },
"scopes": { "base": true, "forum_read": true, "forum_write": true, "forum_moderate": false }
}
scopes is what this token actually holds, and is the only authoritative answer: the user may have
approved less than was asked for, and a token issued before the scopes existed reads as base
alone. Check it once when the token is stored rather than discovering the gap on the first refused
write. The scope field beside it is unrelated — it is the session kind, always "Default".
See /api/v1/auth/self.
Read and write
Every forum endpoint lives under /api/v1/forum. Forum covers the moderation half
and the rules behind all of it; these are the ones an application usually needs:
| Request | Body | What it does |
|---|---|---|
GET /sections | Every section, with a thread count | |
GET /sections/:section_ref?offset&limit | One section and a page of its threads | |
POST /sections/:section_ref | { title, body } | Open a thread |
GET /threads/:thread_ref?offset&limit | One thread and a page of its posts | |
POST /threads/:thread_ref | { body } | Reply |
POST /threads/:thread_ref/read | { up_to_post } | Record that the user has been shown the thread up to that post |
POST /read_floor | Clear every unread counter at once | |
GET /posts/:post_id | One post and a page of its revisions | |
GET /posts/:post_id/source | The same, with the Marktwin source, for an editor | |
PATCH /posts/:post_id | { last_revision_id, content, comment } | Rewrite a post |
:section_ref and :thread_ref accept either a UUID or the resource's key (fr_main);
:post_id is a UUID only, since posts have no key. Pages are offset / limit everywhere,
answered as { offset, limit, count, items }, and GET /api/v1/config gives the page sizes the
website itself uses under forum.threads_per_page and forum.posts_per_page.
Marking a thread read is an explicit write and never a side effect of the GET: a prefetch, a
crawler or a server-side render must not be able to clear a player's unread badge for them. Send it
once the page is actually on screen, or not at all.
Editing takes the last_revision_id of the revision being replaced, which is how two editors
racing each other are detected rather than silently overwriting one another. Fetch the source with
GET /posts/:post_id/source first: every other read omits it.
The sections are a tree
A section has an optional parent and a list of children, and the distinction is not cosmetic:
a top-level section is a category and holds no threads. Only a section that has a parent can be
posted in. Opening a thread in a category is refused with 400, and its self.can_create_thread
is false, so an application that draws its buttons from the self block never offers the button.
GET /sections answers with a flat listing, already ordered so that each parent is immediately
followed by its own children — the order is the administrator's order value, which the payload
itself does not carry. Do not re-sort it; rebuild the tree from parent if you need one.
GET /sections/:section_ref additionally carries children, so one call is enough to draw a
category's landing page.
Write Marktwin, and only what is offered
Posts are written in Marktwin 0.6.0, not HTML and not
Markdown. The subset the server will parse is served to the client, in
ForumSectionSelf.grammar — do not hard-code it. Markup an editor accepts and the server strips is
silently lost when the message is saved, which is exactly what the served grammar exists to
prevent.
What that grammar allows today, for a member and for an application writing as one:
**strong**,_emphasis_,__underline__,~~strikethrough~~and||spoiler||`code`spans and fenced code blocks[big]and[bad][ul],[ol]and[li][quote=author],[rp=author],[aparte]and[collapse=label]- links whose target is
httporhttps
Nesting is capped at four levels, no icon keys are allowed, and @{userId} mentions are off: they
render as an empty span until a client can turn a user id into a name. Read all of this off
grammar rather than off this list, which is a snapshot of one release.
Four blocks are never granted to an access token, whatever scope it holds: [mod], [admin],
[animation] and [announcement]. They announce who is speaking, and an application speaking
through a moderator's account is not that moderator — forum:moderate buys the moderation
commands, not the voice. They reach the grammar from a browser session only, and only at the
matching role.
Draw the buttons the server will honour
Section, thread and post payloads carry a self block computed by the server from the very
predicates its write paths enforce. There is one shape per resource rather than one shared shape:
| Payload | Its self |
|---|---|
| a section | { roles, grammar, unread_threads, can_create_thread } |
| a thread listing row, inside a section | { is_unread } |
a thread, from GET /threads/:thread_ref | { can_post, can_lock, can_pin, can_move, can_delete, can_report } |
| a post, inside a thread | { can_edit, can_delete, can_report } |
"self": { "can_post": true, "can_lock": false, "can_pin": false,
"can_move": false, "can_delete": false, "can_report": false }
GET /posts/:post_id carries no self block at all — it answers with the post, its revisions and
its thread, and the flags live on the listing row inside a thread. unread_threads is filled in by
GET /sections only; GET /sections/:section_ref leaves it at 0, because the thread rows it
already returns say which ones are unread.
Draw a button if and only if the matching flag is set, and do not re-derive the rules from the user's roles. A second implementation of the hierarchy drifts from the first within a release, and the drift is invisible until a player is either handed a button that 403s or denied one they should have had.
When a request is refused
Every refusal answers with { "error": "<sentence>" } and the status carries the meaning:
| Status | Meaning | What to do |
|---|---|---|
| 400 | The Marktwin did not parse, or the section is a category | Fix the request; no authorization changes this |
| 401 | The credentials could not be read at all | The token is broken; start the authorization again |
| 403 | A scope, a role, or the right to speak is missing | See below |
| 404 | No such thread or section — or one hidden from this user | Treat it as absent; it deliberately does not say which |
| 409 | The request is legitimate but already done | Re-read the resource |
| 422 | The request names something real but inconsistent, such as a post from another thread | Fix the request |
A 403 on a write is one of three things, and the application can only act on the first: the
token lacks forum:write or forum:moderate (ask the user for a wider authorization), the user
lacks the role (nothing to do), or the user is muted (nothing to do). Do not retry any of them.
The response does not say which of the three it was — the message is a fixed sentence per endpoint.
Tell them apart before writing rather than after: auth/self gives the token's scopes, and the
self block of the section or thread gives the rest. A write the self block said was allowed
that comes back 403 anyway means the user was muted or demoted in between, and is worth surfacing
to the player rather than retrying.
Failures of the token exchange itself are a separate list, with their own code field: see
OAuth.
Practical notes
- Call from your server, not from the browser. A production server sends no CORS headers at
all — the development profile alone allows one origin,
http://localhost:4200, for the website's own dev build — so afetchfrom a game's page is blocked before it is sent. Forum calls belong in the game's backend, where the access token belongs anyway. - There is no refresh token, and no PKCE. The token endpoint returns an
access_tokenand a very longexpires_in; do not build a refresh cycle around it. If a token stops working, run the authorization flow again. - No client library covers the forum. The official clients — Kotlin, Ruby,
@eternaltwin/client-nodeand the PHPeternaltwin/etwin— coverauth/selfand users and stop there, without a single forum type between them. So the forum is called over plain HTTP with the bearer token. The website's ownForumService(packages/website/src/modules/forum/forum.service.mts) exercises every endpoint and is the reference to read while writing your own.