Eternaltwin

Home | Applications

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:

ScopeWhat it allows
baseNothing on the forum beyond what an anonymous visitor already reads. Granted by default.
forum:readReading the forum as the user, including what is theirs alone (unread counts).
forum:writeOpening a thread, replying, editing their own message, reporting.
forum:moderateThe 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:

RequestBodyWhat it does
GET /sectionsThe sections, with a thread count
GET /sections/:section_ref?offset&limitOne section and a page of its threads
POST /sections/:section_ref{ title, body }Open a thread
GET /threads/:thread_ref?offset&limitOne thread and a page of its posts
POST /threads/:thread_ref{ body }Reply
GET /posts/:post_refOne post and its revisions
GET /posts/:post_ref/sourceThe 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

StatusMeaningWhat to do
401The credentials could not be read at allThe token is broken; start the authorization again
403A scope, a role, or the right to speak is missingSee below
404No such thread or section — or one hidden from this userTreat it as absent; it deliberately does not say which
409The request is legitimate but already doneRe-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 fetch from 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_token and a very long expires_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 cover auth/self and users, so the forum is called over plain HTTP with the bearer token. The website's own ForumService (packages/website/src/modules/forum/forum.service.mts) exercises every endpoint and is the reference to read while writing your own.