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.
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" } } }
}
See /api/v1/auth/self.
Read and write
Every forum endpoint lives under /api/v1/forum. Forum lists them all; these
are the ones an application usually needs:
| Request | Body | What it does |
|---|---|---|
GET /sections | The sections, 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 |
GET /posts/:post_ref | One post and its revisions | |
GET /posts/:post_ref/source | The Marktwin source, for an editor | |
PATCH /posts/:post_ref | { last_revision_id, content, comment } | Rewrite a post |
:section_ref and :thread_ref accept either a UUID or the section's key (fr_main). Pages are
offset / limit; GET /api/v1/config gives the page sizes the website itself uses.
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_ref/source first: reads never carry it.
Write Marktwin, and only what is offered
Posts are written in Marktwin, 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.
An access token is never granted the [mod] and [admin] blocks, whatever scope it holds. Those
blocks announce who is speaking, and an application speaking through a moderator's account is not
that moderator.
Draw the buttons the server will honour
Section, thread and post payloads each carry a self block computed by the server from the very
predicates its write paths enforce:
"self": { "can_post": true, "can_lock": false, "can_pin": false,
"can_move": false, "can_delete": false, "can_report": false }
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
| Status | Meaning | What to do |
|---|---|---|
| 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 |
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.
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. The API's CORS policy does not allow arbitrary
origins, so a
fetchfrom a game's page will be 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 yet. The official clients
(Kotlin,
Ruby,
@eternaltwin/client-node, the PHP SDK) only coverauth/selfand users, 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.