Configuration
Tò He ships four images — api, hub, games, docs — and the same build runs in every environment
(ADR 0021). Everything that differs between
environments is read when a container starts. Local values live in compose.yaml, UAT’s in compose.uat.yaml
(secrets in ~/.config/tohe-uat/uat.env, see UAT).
API
ASP.NET Core configuration: a key Section:Key is the environment variable Section__Key.
| Key | What | Default |
|---|---|---|
ConnectionStrings:Database | PostgreSQL connection string | — required |
Database:MigrateOnStartup | Apply pending migrations when the API starts | false (compose and UAT: true) |
App:PublicUrl | The hub’s public URL, e.g. https://tohe.example.org — no path. With https, every request counts as HTTPS and the session cookie is Secure, named __Host-tohe.session. Emails link to the hub’s pages under it | unset: requests stay as they arrive, emails carry codes without links (development: http://localhost:4720) |
App:ConsoleUrl | The console’s public URL, e.g. https://console.tohe.example.org — a host (or, locally and on UAT, a port) of its own — neither the hub’s (App:PublicUrl) nor the games’ (Games:PublicOrigin), or the API does not start — no path (ADR 0042). The API answers its console routes, /api/admin/…, on that host alone, and nothing else there; with https the console’s cookie is Secure, named __Host-tohe.console. The startup log names <App:ConsoleUrl>/setup while a deployment has no owner (Admin console) | unset: the console’s routes answer 404, and the log says so once (development: http://localhost:4721) |
App:Version | The commit the deployment built, shown by GET /api/config | the assembly’s version, or dev |
Api:Reference:Enabled | Serve the API reference: Scalar at /api/docs and the OpenAPI documents under /api/openapi/. Off, they answer 404; the docs carry the same reference, generated at build time (ADR 0042) | true in Development, false elsewhere (compose: true) |
Games:PublicOrigin | Origin browsers load game iframes from | — required |
Games:CatalogUrl | Where the API fetches catalog.json (often an internal address) | — required |
Registry:SyncInterval | How often the catalog is fetched | 00:05:00 |
Telemetry:Enabled | Server switch for device telemetry (ADR 0015) | true |
Telemetry:OperatorToken | Token for GET /api/telemetry/report | empty: no reports |
Identity:AuthRequestsPerMinute | Requests per client address and minute in each of three buckets: signing in or asking for a code, typing a code back, a PIN or the parent gate (ADR 0024) | 10 |
Identity:SessionCheckInterval | How often a session is checked against its account’s security stamp: after a password reset or an email change, other sessions end within this long | 00:05:00 |
Email:Mode | Smtp to send email, Disabled to send none — features that need email are then unavailable (ADR 0022) | Disabled (compose: Mailpit) |
Email:From | The sender, e.g. Tò He <[email protected]> — an address the relay may send as; Tò He sends from notify. of its domain (Mail and the status page) | — required with Smtp |
Email:Smtp:Host, Email:Smtp:Port, Email:Smtp:Security | The relay; Security is StartTls (587), SslOnConnect (465) or None (a relay on the same network, like Mailpit) | —, 587, StartTls |
Email:Smtp:Username, Email:Smtp:Password | The relay’s credentials (Resend: resend and an API key) | empty: no authentication |
Auth:Providers:Google:ClientId, Auth:Providers:Google:ClientSecret | Google sign-in (ADR 0026): an OAuth client of type Web application with the redirect URI <App:PublicUrl>/api/auth/external/google/callback; step by step in Sign in with Google | unset: no Google |
Auth:Providers:GitHub:ClientId, Auth:Providers:GitHub:ClientSecret | GitHub sign-in: an OAuth app with the callback URL <App:PublicUrl>/api/auth/external/github/callback | unset: no GitHub |
Auth:Providers:Oidc:Authority, …:ClientId, …:ClientSecret, …:DisplayName | One more OpenID Connect issuer (Dex, Keycloak…): its issuer URL, a confidential client with the redirect URI <App:PublicUrl>/api/auth/external/oidc/callback, and the name on its button | unset: none; the name defaults to OpenID |
Auth:Providers:Oidc:RequireHttpsMetadata | false only for a test issuer on plain HTTP | true |
Rooms:* | Timers, bots and limits of online rooms (ADR 0016) | see RoomsOptions |
Integrity:Replays, Integrity:ReplayInterval | Replay ranked runs in the background, and how often to look for new ones (ADR 0030) | true, 00:00:05 |
Admin:SetupToken | The token that makes a new deployment’s first owner, at the console’s /setup (Admin console); keep it in the secret store | unset: made up at start and logged once while there is no owner |
Admin:BootstrapEmails | Addresses allowed to become the first owner (Admin__BootstrapEmails__0, __1…) | empty: any confirmed address with the token |
The API trusts the X-Forwarded-For the hub sends, so nothing but the hub may reach it. Its host filtering
(AllowedHosts) must let both the hub’s host and the console’s through: the hub’s Caddy passes the visitor’s Host on,
and the API tells the console’s requests by it.
A provider exists only with both its client ID and secret; the admin console then switches it on and off, as it does
email and password, and decides who may sign up (ADR 0028,
Admin console). GET /api/config lists the providers that exist
and are on. Providers need
App:PublicUrl with https: the redirect URI is built from the request — HTTPS, because of App:PublicUrl, and the
host the browser used — so players reach the hub at that address, and the providers’ own cookies are Secure.
UAT and local development go without them (a developer may register an app for http://localhost:4720).
Settings from Infisical in production
In production (platform-infra), the API’s settings that the owner sets — the sign-in providers’ client IDs and secrets
first — live in Infisical, folder /tohe/api, one key per setting with __ between its sections
(Auth__Providers__Google__ClientId). An InfisicalStaticSecret copies the folder into the Secret tohe-api-env, which
the chart hands to the API whole with api.extraEnvFrom (optional: the API starts without it). Changing a key restarts
the API (Reloader); no pull request is needed. A setting given in the chart’s values (api.config, api.extraEnv) wins
over the same key in Infisical.
Hub
| Variable | What | Default |
|---|---|---|
API_UPSTREAM | host:port of the API, proxied at /api | — required |
GAMES_ORIGIN | Origin of the game iframes, written into the CSP — the same as the API’s Games:PublicOrigin | — required |
TOHE_TRUSTED_PROXIES | Space-separated CIDRs of proxies in front of the hub that may say who the visitor is — e.g. a cluster’s ingress pods | 127.0.0.1/32: nobody, the hub is the edge |
TOHE_CLIENT_IP_HEADERS | Headers those proxies carry the visitor’s address in, e.g. CF-Connecting-IP behind Cloudflare | X-Forwarded-For |
TOHE_HUB_URL | The hub’s own public URL, e.g. https://toheplay.io.vn: the public pages’ canonical, Open Graph and structured-data links, the app’s own og:url and og:image, llms.txt, the absolute URLs of /sitemap.xml and the Sitemap: line of robots.txt, all written when the container starts | unset: relative links, and /sitemap.xml answers 404 |
TOHE_DOCS_URL | The docs host’s URL, for the public pages’ links to the developer docs | unset: those links lead nowhere |
TOHE_SECURITY_CONTACT | The address /.well-known/security.txt (RFC 9116) names for reporting vulnerabilities, e.g. [email protected]. The file’s Expires is written on each request, 360 days ahead | unset: no security.txt (404) |
Keep TOHE_TRUSTED_PROXIES narrow: anyone inside a trusted range can claim any address, and the API rate-limits
sign-in by address.
Which path the hub serves, and how, comes from the map in @tohe/routes
(Hosts and paths): the root is the public home page (apps/web, in /srv/web),
indexed, with a CSP of its own that allows no inline script or style, and its files under /_web/; the app’s pages get
its index.html with X-Robots-Tag: noindex, its files are served as they are, /api/ is proxied, /robots.txt keeps
crawlers out of the API and names the sitemap, /sitemap.xml lists the indexed pages, and any other path — /admin
among them — answers a real 404 with a small page of its own.
The same image serves the console on a second site, port 8081, for the console’s host: its page at the root of its
namespaces, its files, and the API’s console routes /api/admin/… proxied to API_UPSTREAM — nothing else, a 404
for the rest — with a CSP of its own (GAMES_ORIGIN framed only to watch a ranked run) and
X-Robots-Tag: noindex, nofollow. Publish that port where the console’s host leads (compose: 4721; UAT: 4821; the
chart: the hub Service’s port console).
Games
| Variable | What | Default |
|---|---|---|
HUB_ORIGINS | Space-separated origins allowed to embed games (frame-ancestors): the hub’s, and the console’s, which frames a game to watch a ranked run | — required |
PUBLIC_HUB_URL | The hub’s origin: the games origin’s own front page (/) leads to its /play | — required |
Every response of the games origin carries X-Robots-Tag: noindex, nofollow: a game page is meant only inside the
hub’s frame (ADR 0039).
Docs
The developer docs, at the root of their own host (an old /docs/<page> answers 301 to /<page>).
| Variable | What | Default |
|---|---|---|
PUBLIC_HUB_URL | The hub’s origin: /play on the docs host leads to the hub’s /play | unset: the docs’ own home page |
TOHE_DOCS_URL | The docs host’s own URL, for canonical links, the sitemap and llms.txt | empty: relative links |
In the Helm chart
The chart sets all of the above from its values (Self-hosting): publicUrls.* give
App:PublicUrl, Games:PublicOrigin, GAMES_ORIGIN, HUB_ORIGINS, PUBLIC_HUB_URL, TOHE_HUB_URL and TOHE_DOCS_URL (the last only while docs.enabled);
publicUrls.console gives App:ConsoleUrl, the console’s host in AllowedHosts and HUB_ORIGINS, the hub’s port
console and an Ingress rule for it — empty, none of them (production: https://console.toheplay.io.vn);
hub.proxy.* give TOHE_TRUSTED_PROXIES and TOHE_CLIENT_IP_HEADERS; hub.securityContact gives
TOHE_SECURITY_CONTACT; api.reference.enabled gives Api:Reference:Enabled (default false); infra.postgres.*
and the Secret give ConnectionStrings:Database; api.config adds any other API key.
Every web origin
The hub, the console, the games origin and the docs send Strict-Transport-Security: max-age=31536000; includeSubDomains, and
Tò He is not on the preload list: every host of its domain is Tò He’s and served over HTTPS
(ADR 0042). Browsers ignore it over plain HTTP, so it only
takes effect where the origin is served over HTTPS.