Smart Gateway — the routing tier
When you visit an app at myapp.smart-app.hsuite.network, three things must happen: DNS
turns the name into an address, TLS encrypts the connection, and something must know
which backend serves your app. App hostnames resolve through a wildcard DNS record that
points at the gateways. Smart Gateway does the rest: it terminates the request, works
out which app it belongs to, checks that the backend is genuine, and forwards it. It’s the
lightest of the three tiers: it holds no keys and no business state, just a routing table.
The gateway’s jobs
Section titled “The gateway’s jobs”%%{init: {'flowchart':{'htmlLabels':true,'curve':'basis'}}}%%
flowchart TD
Client([Client]) --> GW["Smart Gateway"]
GW --> Disc["1. Discovery<br/>hosts announce themselves"]
GW --> Route["2. Routing<br/>hostname → app"]
GW --> TLS["3. TLS"]
GW --> RL["4. Rate limiting + host verification"]
Disc --> Pool["Healthy, verified host pool"]
Route --> Pool
Pool --> Backend["Smart Host / smart-app"]
Job 1 — Decentralized host discovery (gossip)
Section titled “Job 1 — Decentralized host discovery (gossip)”The gateway doesn’t keep a hand-maintained list of backends. Hosts announce themselves on the cluster’s message bus when they come online, send heartbeats while they run, and announce when they leave. The gateway only listens. When a host announces itself, the gateway registers it provisionally and verifies it (see Job 4) before routing to it; a host that stops sending heartbeats or leaves is marked unhealthy.
Job 2 — Routing & custom domains
Section titled “Job 2 — Routing & custom domains”Once DNS gets a client to the gateway, the gateway routes by hostname before any of its own routes run:
- Read the request’s hostname.
- Look it up: an app’s
*.smart-appsubdomain, or a custom domain mapped to an app. - Resolve the app’s backend.
- Set the CORS allow-list for that app.
- Forward the request.
Custom-domain registration
Section titled “Custom-domain registration”A custom domain is proven by a DNS TXT record, a CNAME record, or a file served over HTTP, and is then mapped to its app. Mapping a custom domain is not self-serve today: HSuite’s own services do it. Issuing certificates for custom domains automatically is built, not yet enabled (see Job 3).
Owner-affinity — every app has a deterministic home cluster
Section titled “Owner-affinity — every app has a deterministic home cluster”An app is deployed on every cluster, and *.smart-app.hsuite.network can land a request
on any cluster’s gateway. Each cluster keeps its own copy of an app’s live backend state
(its database, its message history). If two requests in one session hit two different
clusters, the app could see two different versions of its data.
So every gateway computes the app’s single owner cluster, and when a request lands on a non-owner gateway, it forwards the request to the owner’s gateway rather than serving a local copy. The owner is derived by a pure hash — no coordination, no lookup table — so every gateway independently computes the same answer:
owner(appId) = sortedActiveClusters[ sha256(appId) first 4 bytes as a big-endian uint32, mod N ]sortedActiveClustersis the active-cluster set from the publicGET /api/v3/discovery/clustersendpoint, sorted so ordering can’t change the answer.Nis how many clusters are active.- The index is the first four bytes of
sha256(appId)read as a big-endianuint32, takenmod N. Because it’ssha256, owners spread evenly across the clusters. - The host applies the same rule to every database write and function call it receives, so writes have one home even if a gateway’s view of the active clusters is briefly out of date.
- Nothing a single cluster holds can change the answer: there is no per-cluster override.
%%{init: {'flowchart':{'htmlLabels':true,'curve':'basis'}}}%%
flowchart TD
Req["Request for app.smart-app.hsuite.network<br/>lands on ANY cluster"] --> GW["That cluster's gateway"]
GW --> Resolve["Resolve the app's owner cluster<br/>owner = sorted clusters, index = hash of appId mod N"]
Resolve --> Q{"Is this cluster the owner?"}
Q -->|yes| Local["Serve the local app instance"]
Q -->|no| Fwd["Forward to the owner gateway<br/>same Host, marked as forwarded"]
Fwd --> Owner["Owner gateway serves its local instance"]
The forward keeps the original hostname, so the owner gateway resolves the same app, and is marked as forwarded, so the owner serves it instead of forwarding it again. The client’s address travels with the forward, signed by the forwarding gateway, so the owner charges the request to the client’s own rate-limit budget.
The fail posture is availability over affinity: if the cluster list is unreachable or empty, the owner is unknown and the gateway serves the local instance — a discovery hiccup must never take every app down. Owner-affinity is on by default.
Peer-proxy failover — any cluster serves everything
Section titled “Peer-proxy failover — any cluster serves everything”Owner-affinity decides which cluster should serve an app. Peer-proxy failover makes sure some cluster does, even when the intended one can’t. Affinity is about consistency; failover is about availability.
When a gateway cannot serve a request locally (the app has no local backend, or the local backend is unreachable), it forwards the request to a peer cluster that can. If the owner can’t be reached, the other peers are tried. The candidate peers come only from the cluster’s own validator discovery, never from anything in the request.
| Situation | What the gateway does |
|---|---|
A POST/PUT/PATCH/DELETE failed locally |
No failover — never replayed; the original 502/504 is returned |
Local backend unreachable, GET/HEAD/OPTIONS |
Try peers |
| The owner cluster can’t be reached | Try the other peers |
| A peer answers 2xx / 3xx / 4xx | A genuine answer — returned as-is |
A peer answers >= 500 |
Skip it, try the next one |
| The app is retired or suspended | 503, no failover |
| No peers available | 502/503, as without failover |
| The overall deadline has passed | 504 Gateway Timeout, no further attempt |
- Writes are never replayed. Only
GET,HEADandOPTIONSfail over. A write that timed out may already have happened, so re-sending it to a peer could run it twice. - One overall deadline. The local attempt, the owner forward and every peer share one time budget, so failover can’t multiply how long a request waits.
- One hop. A failed-over request is marked, and the peer that receives it serves its own local instance and never forwards again.
- A response served by a peer carries an
x-failover-clusterheader naming the cluster that answered.
Peer-proxy failover is on by default.
%%{init: {'flowchart':{'htmlLabels':true,'curve':'basis'}}}%%
flowchart TD
Req["Request lands on a cluster gateway"] --> Try{"Can it serve locally?"}
Try -->|yes| Serve["Serve the local app instance"]
Try -->|"no (backend down)"| Peers["Peer clusters from this cluster's validator"]
Peers --> Loop{"Any peer that can serve?"}
Loop -->|yes| Fwd["Forward once — the peer serves locally"]
Loop -->|"none"| Honest["502 / 503"]
Job 3 — Automatic TLS
Section titled “Job 3 — Automatic TLS”| Certificate | Status |
|---|---|
Wildcard certificate for *.smart-app app hostnames, issued and renewed automatically |
Live |
| Automatic Let’s Encrypt certificates for custom domains | Built, not yet enabled |
Job 4 — Rate limiting, auth, and host verification
Section titled “Job 4 — Rate limiting, auth, and host verification”The gateway applies rate limiting at the edge before traffic reaches a backend, and it verifies a host’s identity, not just that it answers. To verify a host, the gateway sends it a fresh challenge, and the host must sign it with its own Ed25519 key. A host that fails verification is dropped from the pool until it verifies again.
App traffic is rate-limited per app and per calling IP address (for IPv6, per /48 site). Each caller gets 600 requests per sliding 60-second window per app by default, and each cluster keeps its own count.
- Every response carries
X-RateLimit-Limit,X-RateLimit-RemainingandX-RateLimit-Reset. - A request over budget gets
429withRetry-After, the seconds until it would fit, and the app never sees it. - A CORS preflight is answered by the gateway itself, and a browser may reuse the answer for
10 minutes (
Access-Control-Max-Age: 600). - A request to a hostname no app serves is limited per IP address, 100 requests per 60 seconds by default.
- A request forwarded to another cluster is charged there to the original caller.
Two audiences: a public read surface and a cluster-internal control plane
Section titled “Two audiences: a public read surface and a cluster-internal control plane”The rule for the gateway’s own API is read is public, control is internal. The public
routes look things up, such as GET /api/v3/discovery/clusters and the DNS resolve routes
below. The routes that create or change infrastructure (DNS zones and records, domain
mappings, routing state) accept only the platform’s own services and refuse everyone else
with 401.
DNS resolution answers from our own zones — recursion is a privilege
Section titled “DNS resolution answers from our own zones — recursion is a privilege”GET /api/v3/dns/resolve and POST /api/v3/dns/resolve/batch (up to 10 queries) resolve
names for a signed-in session, but only from zones this gateway is authoritative for. A
session that asks for an off-zone name (say wikipedia.org) gets a DNS REFUSED: the
gateway is not an open resolver for the internet.
Recursion — forwarding an off-zone name to upstream resolvers — is never offered to a session. It is available only to the platform’s own services, and only when the operator turns it on; it is off by default.
Metering — a batch is billed per query
Section titled “Metering — a batch is billed per query”DNS resolution is rate-limited and metered like any other call:
- A batch costs one unit per query. A 10-query batch spends 10 units of the rate-limit window and the daily quota, not 1.
- Each session has its own window under the shared per-IP window, so one busy session
can’t drain the budget for every caller behind the same address. Its remaining headroom is
reported in
X-RateLimit-Identity-*headers. - The charge is atomic, so a burst of concurrent requests can’t all spend the same remaining budget.
The full path
Section titled “The full path”%%{init: {'sequence':{'showSequenceNumbers':false}}}%%
sequenceDiagram
autonumber
participant C as Client browser
participant DNS as DNS
participant GW as Smart Gateway
participant Backend as Smart Host / smart-app
C->>DNS: resolve myapp.smart-app.hsuite.network
DNS-->>C: gateway address (wildcard record)
C->>GW: HTTPS request (wildcard certificate)
GW->>GW: match hostname to app, set CORS, rate limit
GW->>GW: owner cluster? forward if not
GW->>Backend: forward to a verified backend
Backend-->>C: response
- The gateway is the routing tier: discovery, routing, TLS and rate limiting in one light service, with no keys and no business state beyond a routing table.
- Hosts announce themselves and the gateway only listens; each host must sign a fresh challenge before the gateway routes to it.
- The gateway routes by hostname. Custom domains are proven by DNS or HTTP, and mapping them is not self-serve today.
- Each app has a deterministic owner cluster (
sha256(appId)mod N over the sorted active clusters), and a non-owner gateway forwards to it, falling back to serving locally if the cluster list is unavailable. - When a cluster can’t serve a request, the gateway fails over to a peer: reads only, one hop, one overall deadline.
- The wildcard certificate for app hostnames is automatic. Automatic certificates for custom domains are built, not yet enabled.
- Read is public, control is internal. DNS resolution answers only from the gateway’s own zones, is metered per query, and never recurses for a session.