/api/v1/apps
The registry of applications built on Eternaltwin: games, tools and the
platform itself. Defined in crates/rest/src/apps.rs.
An app is the product; a channel is one deployment of it — main,
beta, a local development instance — with its own URLs, rating and
visibility. An app always has at least one channel.
There are three routes: two reads and one write. Apps are created by the
seeding configuration ([seed.app] in eternaltwin.local.toml), not through
this API. The write, PATCH /:app_id, edits an existing app
but never touches the fields that come from the configuration (the app and
channel keys, base_uri, home_uri and event_stream_callback_uri): the seed
re-applies them at every start and keeps everything else. The display name is
not in the configuration: it lives in the database, and the app owners change it
here. Neither is the channel order: the seed ranks a channel once, when it
creates it, and the owners then choose the main channel here.
This is not app, which is the desktop client's release feed.
GET /
Lists the registered applications, as a listing of short apps. Authenticated
or not. owners and i18n are filled.
The list is filtered by visibility (see below): an app is returned only if
the caller can see at least one of its channels. count is the number of apps
returned on the page, after this filtering.
Neither limit nor offset is read from the query today: the handler passes
None for both and the service applies its own defaults, a limit of 100.
Example
GET /api/v1/apps
{
"offset": 0,
"limit": 100,
"count": 2,
"items": [
{
"id": "e1484871-29a7-41b1-8c3f-0aa334be1c40",
"created_at": "2021-01-15T14:17:14.015Z",
"deleted_at": null,
"key": "alpha_dev",
"owners": [
{"type": "User", "id": "9f310484-963b-446b-af69-797feec6813f"}
],
"channels": {
"offset": 0,
"limit": 10,
"count": 1,
"items": [{"key": "main"}]
},
"main_channel": {
"id": "5d2b1f18-0c3a-4f9b-8f0b-4f7f8a9c1d20",
"key": "main",
"period": {"start": "2021-01-15T14:17:14.015Z", "end": null},
"main_locale": "en-US",
"description": "Raise your Dinoz",
"description_i18n": {"fr-FR": "Élevez vos Dinoz"},
"base_uri": "http://alpha.localhost/",
"home_uri": "http://alpha.localhost/",
"user_uri": null,
"rating": "General",
"status": "Stable",
"visibility": "Public",
"is_official": false,
"event_stream_callback_uri": null,
"cover_image": {"type": "Asset", "name": "box_alpha"},
"cover_color": null
},
"kind": "Game",
"display_name": "alpha",
"main_locale": "en-US",
"i18n": {"fr-FR": {"display_name": "alpha"}}
}
]
}
| Field | Type | Meaning |
|---|---|---|
id | UUID | Identifier of the app. |
created_at | timestamp | When it was registered. |
deleted_at | timestamp or null | When it was retired. |
key | string or null | Stable handle, ^[a-z][a-z_0-9]{0,99}$. |
owners | array | User references, {"type": "User", "id": …}. |
channels | listing | One entry per visible channel, each {"key": …} only. count counts the visible channels. |
main_channel | channel or null | The main channel: the first channel in rank order. Same shape as a channel of GET /:app_id. null for an app with no channel. |
kind | string | "Platform", "Game" or "Other". |
display_name | string | Name shown to members. |
main_locale | string | Locale the display_name is written in. |
i18n | object | Per-locale overrides, keyed by locale. |
Errors
| Status | Body | When |
|---|---|---|
| 400 | {"error": "invalid \limit` query parameter"}` | The store refused the limit. |
| 500 | {"error": "internal error"} | — |
Visibility
A channel is visible to the caller if its visibility is "Public", or if the
caller is an administrator, or if the caller is an owner of the app. An app is
visible if its main channel, the first one in rank order, is visible: an app
whose main channel is "Private" is hidden from everybody but its owners and
the administrators, even if another of its channels is public. An app with no
channel at all, such as the eternaltwin app that owns the general forums, is
visible to its owners and the administrators only.
GET /omits the apps that are not visible.GET /:app_idanswers404for an app that is not visible, and omits the channels that are not visible from the others.
GET /:app_id
Returns one application, with its visible channels expanded. :app_id is the
UUID — this route does not resolve a key.
Example
GET /api/v1/apps/e1484871-29a7-41b1-8c3f-0aa334be1c40
{
"id": "e1484871-29a7-41b1-8c3f-0aa334be1c40",
"period": {
"start": "2021-01-15T14:17:14.015Z",
"end": null
},
"key": "alpha_dev",
"owners": [
{"type": "User", "id": "9f310484-963b-446b-af69-797feec6813f"}
],
"channels": {
"offset": 0,
"limit": 10,
"count": 1,
"items": [
{
"id": "5d2b1f18-0c3a-4f9b-8f0b-4f7f8a9c1d20",
"key": "main",
"period": {"start": "2021-01-15T14:17:14.015Z", "end": null},
"main_locale": "en-US",
"description": "",
"description_i18n": {},
"base_uri": "http://alpha.localhost/",
"home_uri": "http://alpha.localhost/",
"user_uri": null,
"rating": "General",
"status": "Stable",
"visibility": "Public",
"is_official": false,
"event_stream_callback_uri": null,
"cover_image": null,
"cover_color": null
}
]
},
"kind": "Game",
"display_name": "alpha",
"main_locale": "en-US",
"i18n": {}
}
The full app carries period where the short one carries created_at and
deleted_at; it is the same fact in the shape the rest of the codebase uses
for a lifetime, {"start": …, "end": null} while the app is live.
Channel fields worth naming:
| Field | Type | Meaning |
|---|---|---|
description | string | Description, written in the channel main_locale. |
description_i18n | object | Translations of the description, {locale: text}. |
base_uri | URL | Root of the deployment. |
home_uri | URL | Where a member is sent when they click the app. |
user_uri | URL template or null | Profile page on the app. Contains a {user_id} path component. |
rating | string | "General", "Teen" or "Adult". |
status | string | "Stable", "Beta", "Alpha" or "Dev". |
visibility | string | "Public" or "Private". |
is_official | boolean | Endorsed by the Eternaltwin team. |
cover_image | object or null | Picture on the front of the box: {"type": "Asset", "name": "box_…"}, where name matches ^box_[a-z0-9_]+$ and designates a picture shipped with the website. null when there is none. |
cover_color | string or null | #rrggbb hexadecimal, lowercase (despite the ColorHex8 type name, the pattern accepts 6 digits). |
oauth_allowed_scopes | object, absent | What the OAuth client of the channel may request: {"base": true, "forum_read": true, "forum_write": true, "forum_moderate": false}, the implied scopes filled in. Absent when the channel has no OAuth client ({app}_{channel}@clients, or {app}@clients for production). Never in GET /. |
Status
| Status | When |
|---|---|
| 200 | The app exists and is live. |
| 404 | {"error": "app not found"}. Also returned when the app exists but the caller sees none of its channels. |
| 410 | The app is retired. The body is still the app object, with a closed period. |
| 500 | {"error": "internal error"}. |
PATCH /:app_id
Edits an application and, optionally, some of its channels. Returns the updated
app, in the same shape as GET /:app_id. Only available when the server runs on
Postgres: with the in-memory store the route answers 500{"error": "internal error"} (the store reports not implemented).
Every field is optional. An absent field is left unchanged. null clears
user_uri, cover_color and cover_image; it is not valid for the other fields.
Body
| Field | Type | Meaning |
|---|---|---|
kind | string | "Platform", "Game" or "Other". |
main_locale | string | Locale the main display_name is written in. |
display_name | string | Name in the main locale. |
display_name_i18n | object | {locale: name}. Replaces all the translations. An entry for the main locale is ignored. |
owners | array | Replaces the owners: [{"type": "User", "id": …}], UUIDs only. |
main_channel | string | Key of the channel to rank first, which makes it the main_channel of the app. The other channels keep their order. It must exist. |
channels | array | Channel patches, see below. Channels not listed are unchanged. |
Each entry of channels:
| Field | Type | Meaning |
|---|---|---|
key | string | Required. Channel to edit. It must exist. |
main_locale | string | Locale of the description. |
description | string | Description of the channel. |
description_i18n | object | {locale: text}. Replaces all the translations of the description. An entry for the channel main_locale is ignored. |
user_uri | URL template or null | Profile page on the app, with a {user_id} component. null clears it. |
rating | string | "General", "Teen" or "Adult". |
status | string | "Stable", "Beta", "Alpha" or "Dev". |
visibility | string | "Public" or "Private". |
is_official | boolean | Endorsed by the Eternaltwin team. Administrators only. |
cover_image | object or null | {"type": "Asset", "name": "box_…"}, name matching ^box_[a-z0-9_]+$. null clears it. Any other value is rejected. |
cover_color | string or null | #rrggbb hexadecimal, lowercase (despite the ColorHex8 type name, the pattern accepts 6 digits). null clears it. |
allowed_scopes | string | What the OAuth client of the channel may request, as an OAuth scope string: "base", "forum:read", "forum:write" or "forum:moderate"; the implied scopes are added. Narrowing it does not revoke the tokens already issued. |
Example
PATCH /api/v1/apps/e1484871-29a7-41b1-8c3f-0aa334be1c40
{
"kind": "Other",
"display_name": "Alpha",
"display_name_i18n": {"fr-FR": "alpha"},
"channels": [
{
"key": "main",
"rating": "Teen",
"cover_color": "#ff8800",
"user_uri": null,
"cover_image": {"type": "Asset", "name": "box_alpha"},
"description_i18n": {"fr-FR": "Un jeu"},
"allowed_scopes": "forum:write"
}
]
}
The response is 200 with the updated app:
{
"id": "e1484871-29a7-41b1-8c3f-0aa334be1c40",
"period": {"start": "2021-01-15T14:17:14.015Z", "end": null},
"key": "alpha_dev",
"owners": [
{"type": "User", "id": "9f310484-963b-446b-af69-797feec6813f"}
],
"channels": {
"offset": 0,
"limit": 10,
"count": 1,
"items": [
{
"id": "5d2b1f18-0c3a-4f9b-8f0b-4f7f8a9c1d20",
"key": "main",
"period": {"start": "2021-01-15T14:17:14.015Z", "end": null},
"main_locale": "en-US",
"description": "",
"description_i18n": {"fr-FR": "Un jeu"},
"base_uri": "http://alpha.localhost/",
"home_uri": "http://alpha.localhost/",
"user_uri": null,
"rating": "Teen",
"status": "Stable",
"visibility": "Public",
"is_official": false,
"event_stream_callback_uri": null,
"cover_image": {"type": "Asset", "name": "box_alpha"},
"cover_color": "#ff8800",
"oauth_allowed_scopes": {"base": true, "forum_read": true, "forum_write": true, "forum_moderate": false}
}
]
},
"kind": "Other",
"display_name": "Alpha",
"main_locale": "en-US",
"i18n": {"fr-FR": {"display_name": "alpha"}}
}
Rights
- An administrator can change everything.
- An owner of the app can change everything except
is_official. Inowners, they may only add users: a list that leaves out a current owner is refused. Inallowed_scopes, they choose betweenbase,forum:readandforum:write: grantingforum:moderate, or changing a channel that has it, is refused. - Any other user is refused with
403; a guest with401.
Status
| Status | Body | When |
|---|---|---|
| 200 | The updated app. | The patch was applied. |
| 401 | {"error": "authentication required"} | Guest. |
| 403 | {"error": "forbidden"} | Not an administrator or an owner, or an owner removing owners, touching is_official or forum:moderate. |
| 404 | {"error": "app not found"} | Unknown app. |
| 422 | {"error": "app channel not found"}, "unknown owner", "invalid `user_uri`", "invalid `allowed_scopes`" or "app channel without an OAuth client" | Unknown channel key or main_channel, unknown owner, invalid user_uri, unknown scope, or allowed_scopes on a channel without an OAuth client. |
| 500 | {"error": "internal error"} | Also returned without Postgres. |
A body that is not valid JSON, or that has a field of the wrong type, gets the
default axum rejection (400 or 422).
Seeding
The apps are created by the seeding configuration, [seed.app.<key>]. Besides
the fields that define the app itself, three fields fill the main channel's
presentation:
| Field | Type | Meaning |
|---|---|---|
description | string | Description in the main locale (en-US). Defaults to the app key. |
description_i18n | table | Translations of the description, fr-FR = "…". |
cover_image | string | Name of a cover picture shipped with the website, box_…. |
[seed.app.neoparc] description = "Raise your Dinoz" cover_image = "box_neoparc" [seed.app.neoparc.description_i18n] fr-FR = "Élevez vos Dinoz"
These three fields are applied when the app is created, and at every later start
only while the stored value is still the default an older seed left: a
description equal to the channel's name, no translation, no cover. Once a value
has been set, here or through PATCH /:app_id, a later seed never overwrites it.
This lets the configuration fill apps that existed before these fields did.
The configuration has no display name. A new app, and a new channel, are named
after their key (neoparc, neoparc_staging); an owner then renames the app
with display_name. A display_name key left in an older configuration is
ignored. The OAuth consent screen shows the app's display name.