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.

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:

RequestBodyWhat it does
GET /sectionsEvery section, 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
POST /threads/:thread_ref/read{ up_to_post }Record that the user has been shown the thread up to that post
POST /read_floorClear every unread counter at once
GET /posts/:post_idOne post and a page of its revisions
GET /posts/:post_id/sourceThe 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 http or https

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:

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

StatusMeaningWhat to do
400The Marktwin did not parse, or the section is a categoryFix the request; no authorization changes this
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
422The request names something real but inconsistent, such as a post from another threadFix 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 a fetch from 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_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. The official clients — Kotlin, Ruby, @eternaltwin/client-node and the PHP eternaltwin/etwin — cover auth/self and 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 own ForumService (packages/website/src/modules/forum/forum.service.mts) exercises every endpoint and is the reference to read while writing your own.