0042: Lay out Tò He by trust: one domain, a host per trust boundary, a path per component
- Status: Accepted — the authenticator the console’s sign-in asks for is enrolled on the hub’s account page (Quản lý tài khoản → Bảo mật tài khoản), by an account with a role and a password: nobody signs in to the console without one, so it cannot be added there (Admin console); Cloudflare Access in front of the console is optional for now (owner, 2026-10-20): the console’s own sign-in, a password and a code, is the gate until the owner turns Access on
- Date: 2026-10-19
- Amends: ADR 0040 — the paths on the hub’s host and three new
hosts; ADR 0027 — the console leaves
/admin/for a host of its own; ADR 0021 — the site image is to serve only the developer docs, at the root of its host, and the web origins’ HSTS covers subdomains
Context
ADR 0040 put all of Tò He on toheplay.io.vn: the hub at the root with /api and /admin/, the games origin at
games., and the site — the Vietnamese landing page and the English docs — at docs.. What runs on production since
2026-10-19 shows what that leaves open:
- The name people type is a page machines cannot read. The root is a single-page app: every path answers
200with the same emptyindex.html—/robots.txt,/sitemap.xmland paths that do not exist included — and the HTML carries no description or Open Graph tags. Search engines render it; the crawlers behind AI answers do not run JavaScript. - The introduction lives with the developer docs, at
docs.toheplay.io.vn/, for a different audience in a different language. - The operator’s console shares the players’ origin. A flaw in any page of the hub could act with an operator’s session.
- Paths are written by hand in six places: the hub’s router, about ten links and redirects in the API’s identity module, the console’s links back to the hub, the hub’s Caddyfile, the service worker’s configuration and the end-to-end tests. The hub owns thirteen prefixes at the root, so each new page is one more place two components can collide.
- The app’s identity is its start URL: the manifest has no
id, so movingstart_urlwould make installed apps a different app.
The owner asked for one standard, chosen freely now that changing is cheap: “tôi cần 1 chuẩn hợp lý và hiệu quả … để phân chia hợp lý + mở rộng kỹ thuật tốt”. Three layouts were weighed against the product’s needs — a newcomer playing within seconds, short invitation links, one origin for the app and the player’s session, pages that search engines and AI can read, untrusted games kept apart, a protected console, mail that arrives, the same layout in every environment, room to grow. The owner chose the one below and named the console’s host.
Decision
Four layers.
- One domain,
toheplay.io.vn(ADR 0040). - A host is a trust boundary and an audience. The browser isolates cookies, storage, service workers and permissions by origin, so a security boundary must be a host. Within one audience, more hosts only add CORS, sessions and redirects.
- Inside a host, a component owns a few path prefixes, written in one map (
@tohe/routes) that generates the routing of the hub’s Caddy, the service worker’s list of app pages,robots.txtand the API’s links to the hub. A test fails when two namespaces overlap or a component serves a path outside its own. - Common rules for sessions, the API, headers, indexing, the app, mail, environments and configuration (below).
The hosts.
| Host | Role | Session |
|---|---|---|
toheplay.io.vn | Players: static public pages, the app, the API | __Host-tohe.session |
console.toheplay.io.vn | Operators: the console and the API’s console routes, behind Cloudflare Access | __Host-tohe.console, of its own |
games.toheplay.io.vn | Game code, untrusted (ADR 0002, 0040) | none |
docs.toheplay.io.vn | Developer documentation, at the root of the host | none |
status.toheplay.io.vn | The status page, run by a service outside Tò He’s infrastructure | none |
notify.toheplay.io.vn | Sending transactional mail (no web) | — |
www.toheplay.io.vn | A redirect to the root | — |
Kept for later, without DNS: api. (only for a public API with keys), account. (one sign-in shared by three or more of
Tò He’s own origins), studio. (tools for making games, if they need permissions players do not have), media. (files
people upload; without cookies, or a domain of their own if the risk is high), uat. (UAT’s names, below).
The root’s namespaces.
| Namespace | Component | Served | Indexed |
|---|---|---|---|
/, /games/, /guides/, /faq, /about, /terms, /privacy | public pages (apps/web) | static HTML | yes |
/play, /play/<gameId>/… — only game ids under /play/ | the app | the app’s page | no |
/account/… | the app: signing in and up, password, email, profiles, settings | the app’s page | no |
/r/<code>, /s/<code> | the app’s short links: a table, a share (entry links) | the app’s page | no |
/api/… | the API | proxied | — |
/.well-known/…, /robots.txt, /sitemap.xml | the platform | generated | — |
| anything else | — | a real 404 | — |
The app’s manifest gets id: "/" and start_url: "/play". English route names throughout; a public game page uses the
game’s id (/games/merge-block).
Common rules.
- Sessions (the backend-for-frontend pattern of RFC 10017): each front door serves
/apion its own origin. Session cookies are__Host-,HttpOnly,Secure,Path=/, and never carry aDomain; the players’ isSameSite=Lax(links in mail, OAuth), the console’sSameSite=Strict. No CORS. - One API, several front doors; it knows its origins from configuration (
publicUrls). A separateapi.host only for third parties. - No third-party script on the root or on
console.; a CSP for each host and namespace. - HSTS
max-age=31536000; includeSubDomains— every host of the domain is HTTPS — and no preload list. - Indexing: the public pages on the root and the docs; nothing else.
- Mail goes out from
notify.; people write tohello@andsecurity@at the root. - Environments keep the role names: production
<role>.toheplay.io.vn, one level deep (Cloudflare’s free certificate); UAT, if it gets HTTPS,uat.toheplay.io.vnand<role>.uat.toheplay.io.vn, DNS-only to its private address with a DNS-01 certificate; development onlocalhostports. - Hosts come from configuration, never from code. A new host needs an ADR; a new namespace is one line in the map.
Adding a component. Another host when its code is not ours or not trusted, when it must stay up while Tò He is down, or when its people, permissions or session differ from the players’. Otherwise a namespace on the root — served from the hub’s image, or by a service of its own behind the same prefix, which changes no URL.
Consequences
- The work arrives in steps, each a pull request: the map and its generated configuration with real
404s,robots.txt,security.txt, HSTS for subdomains and a switch for the API reference; the app’s routes under/playand/account; the public pages asapps/webbuilt into the hub’s image with theirsitemap.xml, and the docs at the root of their host (the site app renamedapps/docs, the chart’ssitevalues renameddocs); the console’s host; mail fromnotify.and the status page. Until each lands, the current layout stays in force for its part. - Links already sent stay valid:
/r/<code>keeps its form; links in mail that are still valid redirect to their new paths for a while. - The console gets a sign-in of its own; an operator signed in as a player is not signed in to the console. Cloudflare Access asks first.
platform-infrachanges three times — the security contact (hub.securityContact), the docs values and the console’s host — in pull requests the owner merges.- Until the public pages arrive, the root is the app’s catalog and, like every page of the app, says
noindex. - The status page shares the domain’s DNS with Tò He, so a DNS failure takes both down; the owner’s community channels are the fallback.
- Public pages share the app’s origin: they carry no third-party code, and the service worker leaves them alone.
Alternatives considered
- Keep ADR 0040’s paths and add meta tags and a
robots.txtto the app. Least work; the root would still be empty to the crawlers behind AI answers, and the introduction would stay with the developer docs. - The introduction at the root and the app at
play.. Two front doors for players: every link one hop longer, the app installed from another host, and the introduction unable to tell a returning player from a new one. Its one gain — an introduction that stays up while the cluster is down — can come later from routing paths at Cloudflare. - A host for everything (
app.,api.,auth.…). CORS and sessions across hosts for one audience, against the same-origin pattern of RFC 10017. - Rendering the app on the server. A different framework for the hub to solve what static pages in front of it solve.