Skip to content

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.

KeyWhatDefault
ConnectionStrings:DatabasePostgreSQL connection string— required
Database:MigrateOnStartupApply pending migrations when the API startsfalse (compose and UAT: true)
App:PublicUrlThe 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 itunset: requests stay as they arrive, emails carry codes without links (development: http://localhost:4720)
App:ConsoleUrlThe 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:VersionThe commit the deployment built, shown by GET /api/configthe assembly’s version, or dev
Api:Reference:EnabledServe 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:PublicOriginOrigin browsers load game iframes from— required
Games:CatalogUrlWhere the API fetches catalog.json (often an internal address)— required
Registry:SyncIntervalHow often the catalog is fetched00:05:00
Telemetry:EnabledServer switch for device telemetry (ADR 0015)true
Telemetry:OperatorTokenToken for GET /api/telemetry/reportempty: no reports
Identity:AuthRequestsPerMinuteRequests 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:SessionCheckIntervalHow often a session is checked against its account’s security stamp: after a password reset or an email change, other sessions end within this long00:05:00
Email:ModeSmtp to send email, Disabled to send none — features that need email are then unavailable (ADR 0022)Disabled (compose: Mailpit)
Email:FromThe 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:SecurityThe relay; Security is StartTls (587), SslOnConnect (465) or None (a relay on the same network, like Mailpit)—, 587, StartTls
Email:Smtp:Username, Email:Smtp:PasswordThe relay’s credentials (Resend: resend and an API key)empty: no authentication
Auth:Providers:Google:ClientId, Auth:Providers:Google:ClientSecretGoogle 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 Googleunset: no Google
Auth:Providers:GitHub:ClientId, Auth:Providers:GitHub:ClientSecretGitHub sign-in: an OAuth app with the callback URL <App:PublicUrl>/api/auth/external/github/callbackunset: no GitHub
Auth:Providers:Oidc:Authority, …:ClientId, …:ClientSecret, …:DisplayNameOne 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 buttonunset: none; the name defaults to OpenID
Auth:Providers:Oidc:RequireHttpsMetadatafalse only for a test issuer on plain HTTPtrue
Rooms:*Timers, bots and limits of online rooms (ADR 0016)see RoomsOptions
Integrity:Replays, Integrity:ReplayIntervalReplay ranked runs in the background, and how often to look for new ones (ADR 0030)true, 00:00:05
Admin:SetupTokenThe token that makes a new deployment’s first owner, at the console’s /setup (Admin console); keep it in the secret storeunset: made up at start and logged once while there is no owner
Admin:BootstrapEmailsAddresses 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

VariableWhatDefault
API_UPSTREAMhost:port of the API, proxied at /api— required
GAMES_ORIGINOrigin of the game iframes, written into the CSP — the same as the API’s Games:PublicOrigin— required
TOHE_TRUSTED_PROXIESSpace-separated CIDRs of proxies in front of the hub that may say who the visitor is — e.g. a cluster’s ingress pods127.0.0.1/32: nobody, the hub is the edge
TOHE_CLIENT_IP_HEADERSHeaders those proxies carry the visitor’s address in, e.g. CF-Connecting-IP behind CloudflareX-Forwarded-For
TOHE_HUB_URLThe 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 startsunset: relative links, and /sitemap.xml answers 404
TOHE_DOCS_URLThe docs host’s URL, for the public pages’ links to the developer docsunset: those links lead nowhere
TOHE_SECURITY_CONTACTThe 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 aheadunset: 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

VariableWhatDefault
HUB_ORIGINSSpace-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_URLThe 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>).

VariableWhatDefault
PUBLIC_HUB_URLThe hub’s origin: /play on the docs host leads to the hub’s /playunset: the docs’ own home page
TOHE_DOCS_URLThe docs host’s own URL, for canonical links, the sitemap and llms.txtempty: 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.