NNO Docs
ArchitectureServices

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 PrefixService BindingFallback Env VarInternal ServiceDescription
/api/v1/iamNNO_IAMNNO_IAM_URLservices/iamAuth, sessions, organizations, roles, grants
/api/v1/platformsNNO_REGISTRYNNO_REGISTRY_URLservices/registryPlatforms, tenants, resources, feature activations
/api/v1/billingNNO_BILLINGNNO_BILLING_URLservices/billingStripe subscriptions, metering, invoicing (billing mounts routes at / internally — prefix is stripped but not re-prepended when forwarding)
/api/v1/provisioningNNO_PROVISIONINGNNO_PROVISIONING_URLservices/provisioningCloudflare resource job state machine
/api/v1/stacksNNO_STACK_REGISTRYNNO_STACK_REGISTRY_URLservices/stack-registryVersioned NNO-authored stack template catalogue
/api/v1/cliNNO_CLINNO_CLI_SERVICE_URLservices/cliPlatform GitHub repo management + CF Pages build triggers
/api/v1/onboardingNNO_REGISTRYhttp://localhost:8788/api/v1/onboardingservices/registrySelf-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.ts exports GATEWAY_ROUTES as a typed const array mirroring this table. It is CI-diffable against wrangler.toml to 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)

  1. Extracts the Authorization: Bearer \{token\} header
  2. Compares the token against AUTH_API_KEY via constant-time comparison (timingSafeEqual)
  3. On match, forwards the request immediately — no header augmentation

Path 2 — IAM session token (user requests)

  1. If Path 1 fails and IAM is configured (NNO_IAM binding or NNO_IAM_URL), validates the token via GET /api/nno/session on the IAM service
  2. On success, injects user context headers before forwarding:
    • x-nno-user-id — the authenticated user's ID
    • x-nno-role — the user's platform role (e.g. user, platform-admin)
    • x-nno-platform-id — the user's active platform ID (when present)
  3. 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-token with \{ serviceId: "gateway", targetService: "<name>" \} and an Authorization: Bearer \{NNO_INTERNAL_API_KEY\} header
  • Cache key: target service name (e.g. "registry", "billing")
  • TTL: cache entry is refreshed at expiresIn - 30 seconds 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, returns null — the proxy falls back to the static NNO_INTERNAL_API_KEY
  • Signature: proxyRequest always injects X-NNO-Signature and X-NNO-Timestamp headers (HMAC-SHA256 signed over userId:role:platformId:requestId:timestamp using NNO_INTERNAL_API_KEY as the signing key) so upstream services can independently verify the injected x-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:

#MiddlewareSourceScopeNotes
1errorHandlerMiddlewarelocal: middleware/error-handler.ts*Standardized NNO error envelope for unhandled errors
2requestIdMiddlewarelocal: middleware/request-id.ts*Generates/propagates x-request-id
3requestLoggerexternal: @neutrino-io/logger*Structured request logging
4timingexternal: hono/timing*Server-Timing header
5rateLimiterlocal: middleware/rate-limiter.ts*CF Rate Limiting API with in-memory fallback, 5 tiers via [[unsafe.bindings]]
6corsexternal: hono/cors/api/*Configured per-environment origin allowlist
7tracingMiddlewarelocal: middleware/tracing.ts*Distributed tracing, imports from @neutrino-io/core/tracing
8authMiddlewarelocal: middleware/auth.ts/api/*Three-path auth (see Auth section)
9platformLifecycleMiddlewarelocal: middleware/platform-lifecycle.ts/api/*Platform status enforcement at gateway edge

Note: middleware/security-headers.ts exists as a file but is NOT currently registered in index.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 /health is currently implemented. Downstream service /health endpoints 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 NameTarget Worker PatternUsed For
NNO_IAMnno-\{platformId\}-iam-\{env\}Auth token validation + IAM API forwarding
NNO_REGISTRYnno-\{platformId\}-registry-\{env\}Platform/tenant/resource management
NNO_BILLINGnno-\{platformId\}-billing-\{env\}Stripe billing + metering
NNO_PROVISIONINGnno-\{platformId\}-provisioning-\{env\}CF resource provisioning jobs
NNO_STACK_REGISTRYnno-\{platformId\}-stack-registry-\{env\}Stack template catalogue
NNO_CLInno-\{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:

  1. On every authenticated request, read platform:\{platformId\}:status from NNO_PLATFORM_STATUS_KV
  2. If status is suspended, pending_cancellation, or cancelled, return 403 PLATFORM_SUSPENDED
  3. Operator-role users (x-nno-role: operator) bypass suspension checks
  4. The /health endpoint 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):

FilePurpose
services/proxy.tsproxyRequest() — 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.tsServiceTokenCache — 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 Sentry
  • SENTRY_DSN and SENTRY_RELEASE are configured as environment variables in wrangler.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

On this page