Gateway
The front door: authentication, routing, CORS, limits and the internal identity token.
The gateway is a Pingora proxy on 127.0.0.1:8000 by default (CHELABASE_GATEWAY_LISTEN takes a list, e.g. 127.0.0.1:8000,[::1]:8000). It routes by host name: the API host serves the APIs, and the Studio, docs, landing and mail hosts are passed through to their apps. The auth host serves nothing (every path is 404): it only names the issuer of member tokens. All API traffic goes through it: the Management API (/v1/*), the login API (/auth/v1/*) and the data API (/data/v1/*). It never reads request bodies.
One declaration per endpoint
Every public endpoint is declared once, in the chelabase-routes crate: method, path, owning service, who may call it and which origins may use it from a browser. The gateway routes and applies policy from that list; the owning service builds its axum routes from the same list, so the two cannot drift. The OpenAPI tests also check each document against it (see API generation).
Each request goes through a fixed pipeline: request id, host scope, route, CORS, authentication, internal identity. Nothing is configurable per route except the declared policy.
| Policy | Meaning |
|---|---|
| Public | No credential (for example GET /health, the sign-in routes, the API reference) |
| Management | A member access token (JWT) |
| Data key | A data API key of either kind (chk_pub_ or chk_sec_) |
| Secret data key | Only a secret key (GET /data/v1/schema) |
| Public data key | Only a public key (the app-user sign-in flows); a secret key is refused |
Management API requests
Management endpoints require Authorization: Bearer <member access token>, the EdDSA JWT that auth signs at POST /auth/v1/token (see Identity service). The gateway verifies it locally with the member public key (CHELABASE_MEMBER_JWT_PUBLIC_KEY): the algorithm, key id, issuer (derived from CHELABASE_AUTH_HOST and CHELABASE_PUBLIC_SCHEME; see Hosts), audience chelabase-member, claim set and lifetime are all pinned. Without a verify key every bearer is a 401 and the gateway reports itself not ready. It then forwards the request with an internal identity token in the x-chelabase-identity header:
- an EdDSA JWT, valid for 60 seconds, signed for exactly one target service (its
audisorchestrator,auth,restorrealtime); - the gateway never forwards
Authorization, theChelabase-Userheader or a client-suppliedx-chelabase-identity.
The internal token carries the member's tenant (the member token's tid). The orchestrator trusts only that token (it holds the verify key, CHELABASE_INTERNAL_VERIFY_KEY) and still requires an active membership in that tenant on every request. Calling the orchestrator's port :8081 directly returns 401.
init-secrets (or just workbench up on a workbench stack) creates the gateway's key pair once; it is never overwritten.
Data API requests
A data API request carries Authorization: Bearer <key>. The gateway:
- hashes the key (SHA-256) and looks the hash up in a cache (30 seconds; unknown keys are cached for 30 seconds too);
- on a miss, asks the orchestrator's internal listener for the key's branch, role, project and database allow-list;
- if the request carries
Chelabase-User: <access token>(an app user), verifies that token with the app-user public key and checks that its session is still live (cached 30 seconds). A user token is refused with a secret key (400user_token_with_secret_key), and the request then runs asauthenticatedwith the user's claims; an invalid, expired or revoked token is a 401, never a fall back to the key's own role; - mints an identity token for
restthat carries the branch, key id, role, claims, project and database allow-list.
rest enforces the allow-list, because the gateway never reads bodies. A revoked key stops working within 30 seconds, the cache lifetime.
Cache misses are rate limited to 20 per second per client address, and a request over the limit gets 429 rate_limited. The limiter keys on the connection's address, or on the client address forwarded by a proxy listed in CHELABASE_GATEWAY_TRUSTED_PROXIES; it ignores X-Forwarded-For from anyone else, and groups IPv6 clients by their /64 prefix. It remembers at most 100,000 addresses (idle ones are forgotten after 60 seconds); past that it evicts entries, and an evicted address starts again with a full allowance. Behind a load balancer every client shares one address.
The gateway answers its own 404 and 405 under /data/ with the data API's error body (code invalid_query), so a client sees one error shape for the whole prefix.
Forwarding and CORS
- The gateway sets
X-Forwarded-Forto the client's address (never trust it for authorization),X-Forwarded-Hostto the request's host, andX-Forwarded-PrototoCHELABASE_PUBLIC_SCHEME. It speaks plain HTTP itself. - Studio endpoints allow Studio's origin (from
CHELABASE_STUDIO_HOST); the sign-in routes also allow cookies for it. The data API endpoints allow any origin (Access-Control-Allow-Origin: *, never with credentials), so browsers can call them with a public key. - Only
GET /v1/realtime/sockettakes a protocol upgrade, and onlyUpgrade: websocket; its socket may sit idle up to an hour both ways. Any other API request that asks for an upgrade gets 400invalid_requestbefore it reaches an upstream. - A host the gateway does not serve gets a 404 with an empty body, and a request without a
Hosta 400 with an empty body: nothing in the answer names the gateway or its hosts.
Health
GET /health is public and answered by the orchestrator. GET /_gateway/health is the gateway's own liveness check, answered before any host or route check.
Settings
| Variable | Default | Meaning |
|---|---|---|
CHELABASE_GATEWAY_LISTEN | 127.0.0.1:8000 | Comma-separated listen addresses |
CHELABASE_ORCHESTRATOR_URL | http://127.0.0.1:8081 | Management API upstream |
CHELABASE_AUTH_URL | http://127.0.0.1:8084 | Login API upstream |
CHELABASE_REST_URL | http://127.0.0.1:8085 | Data API upstream |
CHELABASE_REALTIME_URL | http://127.0.0.1:8087 | Realtime upstream (tickets and the websocket) |
CHELABASE_API_HOST, CHELABASE_STUDIO_HOST, CHELABASE_AUTH_HOST | none (required) | The hosts that serve the API and Studio, and the one that names the member tokens' issuer |
CHELABASE_DOCS_HOST, CHELABASE_LANDING_HOST, CHELABASE_MAIL_HOST | unset | Optional hosts for the docs site, the landing page and Mailpit |
CHELABASE_PUBLIC_SCHEME | https | The scheme clients use; derives Studio's origin and the issuer |
CHELABASE_GATEWAY_TRUSTED_PROXIES | none | Reverse proxies whose forwarded client address is believed: CIDRs or addresses, comma-separated |
CHELABASE_GATEWAY_UPSTREAM_TIMEOUT_SECS | 60 | Upstream read timeout |
CHELABASE_INTERNAL_URL | http://127.0.0.1:8083 | The orchestrator's internal listener, for key lookups |
CHELABASE_DATA_KEYS_TOKEN | chelabase-dev-data-keys | Bearer for key lookups |
CHELABASE_APP_SESSIONS_INTERNAL_TOKEN | chelabase-dev-app-sessions | Bearer for app-user session liveness |
CHELABASE_MEMBER_JWT_PUBLIC_KEY | unset | Member token verify key (a PEM file, auth/member-jwt.pub); unset, every bearer is refused |
CHELABASE_APP_JWT_PUBLIC_KEY | unset | App-user token verify key (a PEM file); unset, every Chelabase-User header is refused |
CHELABASE_INTERNAL_SIGNING_KEY | none (required) | The key that signs identity tokens (gateway/internal-signing.pem) |
The development bearer values (chelabase-dev-…) are used only with CHELABASE_DEV_DEFAULTS=1 (the workbench); otherwise an unset or development bearer stops the service at start-up. Each bearer can also be given as a file through <NAME>_FILE.