NNO Gateway
Documentation for NNO Gateway
Date: 2026-03-30
Status: Detailed Design
Parent: System Architecture — Section 3, Layer 0 (NNO Core)
Service: services/gateway/
Overview
The NNO Gateway is the single API entry point for all NNO platform traffic. It is a stateless Hono Worker deployed on Cloudflare Workers that routes incoming /api/* requests to internal NNO services. It enforces authentication on all API routes (via NNO IAM) and returns standardised error envelopes for downstream failures.
The gateway does not cache responses, retry failed requests, or hold any state. It exists solely to multiplex, authenticate, and forward.
Routing Table [Phase 1]
The gateway proxies all /api/* traffic to internal NNO services via Cloudflare Service Bindings (deployed) or HTTP URL fallbacks (local dev). All routes require authentication (enforced by authMiddleware on /api/*) except the /health endpoint.
Prefix stripping: the gateway strips the full route prefix before forwarding. A request to GET /api/v1/platforms/abc reaches the Registry Worker as GET /abc.
| Gateway Prefix | Service Binding | Fallback Env Var | Internal Service | Description |
|---|---|---|---|---|
/api/v1/iam | NNO_IAM | NNO_IAM_URL | services/iam | Auth, sessions, organizations, roles, grants |
/api/v1/platforms | NNO_REGISTRY | NNO_REGISTRY_URL | services/registry | Platforms, tenants, resources, feature activations |
/api/v1/billing | NNO_BILLING | NNO_BILLING_URL | services/billing | Stripe subscriptions, metering, invoicing (billing mounts routes at / internally — prefix is stripped but not re-prepended when forwarding) |
/api/v1/provisioning | NNO_PROVISIONING | NNO_PROVISIONING_URL | services/provisioning | Cloudflare resource job state machine |
/api/v1/stacks | NNO_STACK_REGISTRY | NNO_STACK_REGISTRY_URL | services/stack-registry | Versioned NNO-authored stack template catalogue |
/api/v1/cli | NNO_CLI | NNO_CLI_SERVICE_URL | services/cli | Platform GitHub repo management + CF Pages build triggers |
/api/v1/onboarding | NNO_REGISTRY | http://localhost:8788/api/v1/onboarding | services/registry | Self-serve platform onboarding pipeline |
Accepted HTTP methods: GET, POST, PUT, PATCH, DELETE (both exact prefix and prefix/* wildcard).
Code artifact:
services/gateway/src/routes-snapshot.tsexportsGATEWAY_ROUTESas a typed const array mirroring this table. It is CI-diffable againstwrangler.tomlto detect routing drift.
Per-Platform Auth DNS [Updated for DNS architecture]
Each client platform has a dedicated auth Worker served at auth.svc.default.<platformId>.nno.app. The gateway does not route to per-platform auth workers — per-platform auth is called directly by the platform's console shell using the VITE_AUTH_API_URL environment variable (e.g. https://auth.svc.default.a1b2c3d4e5.nno.app).
The gateway's own auth middleware calls the NNO IAM service (iam.svc.nno.app) to validate session tokens — not the per-platform auth workers. This separation means the gateway is stateless with respect to platform-specific auth state.
See dns-naming.md for the full DNS hostname convention.
Auth Middleware [Phase 1]
All /api/* routes pass through authMiddleware before reaching the downstream service. The middleware implements a two-path auth model:
Path 1 — Static API key (service-to-service)
- Extracts the
Authorization: Bearer \{token\}header - Compares the token against
AUTH_API_KEYvia constant-time comparison (timingSafeEqual) - On match, forwards the request immediately — no header augmentation
Path 2 — IAM session token (user requests)
- If Path 1 fails and IAM is configured (
NNO_IAMbinding orNNO_IAM_URL), validates the token viaGET /api/nno/sessionon the IAM service - On success, injects user context headers before forwarding:
x-nno-user-id— the authenticated user's IDx-nno-role— the user's platform role (e.g.user,platform-admin)x-nno-platform-id— the user's active platform ID (when present)
- Downstream services read these headers to enforce role-based access (e.g. stack-registry operator-only endpoints check
x-nno-role)
Phase 2 addition — Path 3 — NNO API key: If the request includes an x-api-key header, Gateway forwards the key to IAM POST /api/nno/apikey/validate, which validates the SHA-256 hash against the Better Auth API key store and returns \{ userId, role, platformId \}. This is consistent with the existing IAM session validation pattern. The same user context headers (x-nno-user-id, x-nno-role, x-nno-platform-id) are injected before forwarding.
Failure: if all auth paths fail (or no auth header is present), returns 401 Unauthorized using the NNO error envelope:
{ "error": { "code": "UNAUTHORIZED", "message": "...", "requestId": "..." } }Local dev: when AUTH_API_KEY is not set, all requests pass through without authentication.
The gateway does not issue tokens — that is the IAM service's responsibility. The gateway only validates.
Service-to-Service JWT Auth [Phase 1]
The gateway acquires short-lived JWTs from IAM for upstream service-to-service authentication via ServiceTokenCache (services/gateway/src/services/service-token.ts):
- Flow: gateway POSTs to IAM
POST /api/nno/service-tokenwith\{ serviceId: "gateway", targetService: "<name>" \}and anAuthorization: Bearer \{NNO_INTERNAL_API_KEY\}header - Cache key: target service name (e.g.
"registry","billing") - TTL: cache entry is refreshed at
expiresIn - 30seconds before expiry to avoid serving near-expired tokens - Fallback: if neither a Service Binding (
NNO_IAM) nor an HTTP URL (NNO_IAM_URL) is available, returnsnull— the proxy falls back to the staticNNO_INTERNAL_API_KEY - Signature:
proxyRequestalways injectsX-NNO-SignatureandX-NNO-Timestampheaders (HMAC-SHA256 signed overuserId:role:platformId:requestId:timestampusingNNO_INTERNAL_API_KEYas the signing key) so upstream services can independently verify the injectedx-nno-*context headers - IAM validation: IAM validates service tokens using inline HMAC constant-time comparison via
crypto.subtle
CORS [Phase 1]
Configured on all /api/* routes via the CORS_ORIGINS environment variable (comma-separated, supports *.domain wildcard patterns). Default origins for local dev: http://localhost:5174, http://localhost:5175.
Middleware Pipeline [Phase 1]
All middleware is registered in services/gateway/src/index.ts in the following order:
| # | Middleware | Source | Scope | Notes |
|---|---|---|---|---|
| 1 | errorHandlerMiddleware | local: middleware/error-handler.ts | * | Standardized NNO error envelope for unhandled errors |
| 2 | requestIdMiddleware | local: middleware/request-id.ts | * | Generates/propagates x-request-id |
| 3 | requestLogger | external: @neutrino-io/logger | * | Structured request logging |
| 4 | timing | external: hono/timing | * | Server-Timing header |
| 5 | rateLimiter | local: middleware/rate-limiter.ts | * | CF Rate Limiting API with in-memory fallback, 5 tiers via [[unsafe.bindings]] |
| 6 | cors | external: hono/cors | /api/* | Configured per-environment origin allowlist |
| 7 | tracingMiddleware | local: middleware/tracing.ts | * | Distributed tracing, imports from @neutrino-io/core/tracing |
| 8 | authMiddleware | local: middleware/auth.ts | /api/* | Three-path auth (see Auth section) |
| 9 | platformLifecycleMiddleware | local: middleware/platform-lifecycle.ts | /api/* | Platform status enforcement at gateway edge |
Note:
middleware/security-headers.tsexists as a file but is NOT currently registered inindex.ts.
Health Endpoint [Phase 1]
The gateway exposes a public health endpoint (no auth required):
GET /health → 200 { status: "healthy", service: "gateway", environment: string, timestamp: string }Convention for downstream services:
GET /health → 200 { status: "ok", service: "<service-name>" }Phase 1: only the gateway
/healthis currently implemented. Downstream service/healthendpoints are planned but not yet required.
Resilience [Phase 1]
Downstream Failure Behavior [Phase 1]
When a downstream service is unreachable or returns a 5xx error, the gateway returns 502 Bad Gateway with a standard NNO error envelope:
{
"error": {
"code": "UPSTREAM_ERROR",
"message": "Service temporarily unavailable",
"details": { "service": "<service-name>" },
"requestId": "<x-request-id>"
}
}The gateway is stateless — it does not cache responses or retry failed requests.
See Platform Suspension Enforcement section below for the Phase 2 403 PLATFORM_SUSPENDED behavior.
Circuit Breaking (Phase 1: Out of Scope)
Circuit breaking is explicitly out of scope for Phase 1. The gateway forwards requests directly to downstream services without retry logic, exponential backoff, or half-open state tracking.
Rationale: Cloudflare Service Bindings have sub-millisecond Worker-to-Worker overhead and are inherently resilient within a CF PoP. At Phase 1 scale, the operational complexity of a circuit breaker outweighs its benefits.
Phase 2 decision point: If sustained downstream failures are observed at scale, introduce a per-route circuit breaker (token bucket or sliding-window counter in KV).
Service Bindings [Phase 1]
The gateway uses Cloudflare Service Bindings for Worker-to-Worker calls in deployed environments. Service bindings provide sub-millisecond overhead and avoid public network egress. Each binding maps to an NNO internal service Worker:
| Binding Name | Target Worker Pattern | Used For |
|---|---|---|
NNO_IAM | nno-\{platformId\}-iam-\{env\} | Auth token validation + IAM API forwarding |
NNO_REGISTRY | nno-\{platformId\}-registry-\{env\} | Platform/tenant/resource management |
NNO_BILLING | nno-\{platformId\}-billing-\{env\} | Stripe billing + metering |
NNO_PROVISIONING | nno-\{platformId\}-provisioning-\{env\} | CF resource provisioning jobs |
NNO_STACK_REGISTRY | nno-\{platformId\}-stack-registry-\{env\} | Stack template catalogue |
NNO_CLI | nno-\{platformId\}-cli-service-\{env\} | GitHub repo + CF Pages builds |
In local development (wrangler dev), service bindings are not available. The gateway falls back to HTTP URLs configured via environment variables (NNO_IAM_URL, NNO_REGISTRY_URL, etc.).
See Platform Suspension Enforcement below for the Phase 2 NNO_PLATFORM_STATUS_KV binding.
Platform Suspension Enforcement [Phase 2]
The Gateway enforces platform suspension at the edge via KV lookup:
- On every authenticated request, read
platform:\{platformId\}:statusfromNNO_PLATFORM_STATUS_KV - If status is
suspended,pending_cancellation, orcancelled, return403 PLATFORM_SUSPENDED - Operator-role users (
x-nno-role: operator) bypass suspension checks - The
/healthendpoint is exempt from suspension checks
KV Binding: NNO_PLATFORM_STATUS_KV (configured in wrangler.toml for stg/prod). This is a read-only KV usage — Gateway never writes to the namespace.
Write path: When a platform's status is changed via Registry, Registry writes platform:\{platformId\}:status to this KV namespace. Gateway reads the value on every authenticated /api/* request (sub-millisecond, near-instant invalidation).
Error response:
{ "error": { "code": "PLATFORM_SUSPENDED", "message": "Platform is suspended", "requestId": "..." } }This is a Phase 2 feature. Phase 1 does not enforce suspension at the Gateway — status checks happen in downstream services.
Gateway Services [Phase 1]
Internal services used by the gateway (not middleware — invoked per-request inside route handlers):
| File | Purpose |
|---|---|
services/proxy.ts | proxyRequest() — forwards the incoming request to the upstream service. Strips the route prefix, injects auth headers (Authorization: Bearer), user context headers (x-nno-*), and the HMAC signature (X-NNO-Signature, X-NNO-Timestamp). Returns a structured 502 on upstream 5xx. |
services/service-token.ts | ServiceTokenCache — acquires and caches short-lived JWTs from IAM for service-to-service auth. One instance is created at module level so the cache persists across requests within the same Worker isolate. |
Error Tracking [Phase 1]
The entire gateway app export is wrapped with Sentry.withSentry() from @sentry/cloudflare:
export default Sentry.withSentry(
(env: Env) => ({
dsn: env.SENTRY_DSN,
tracesSampleRate: 0.1,
release: env.SENTRY_RELEASE ?? "unknown",
}),
{
fetch: (request: Request, env: Env, ctx: ExecutionContext) =>
app.fetch(request, env, ctx),
},
);tracesSampleRate: 0.1— 10% of traces are sampled and sent to SentrySENTRY_DSNandSENTRY_RELEASEare configured as environment variables inwrangler.toml
Distributed Tracing [Phase 1]
tracingMiddleware (middleware/tracing.ts) imports from @neutrino-io/core/tracing, which exports:
extractSpanContext— reads incoming trace headers and returns\{ requestId, traceId \}X_REQUEST_ID,X_TRACE_ID,X_SPAN_ID— canonical header name constants
Trace IDs are stored in the Hono context (via c.set()) and threaded through to proxyRequest for propagation to upstream services as X-Request-Id and x-trace-id headers.
Status: Detailed design
Implementation target: services/gateway/
Related: System Architecture · Auth Model · Registry · Provisioning · Billing & Metering · Stack Registry · CLI Service