logicspike/docs

gateway

Gateway — Developer Guide

Last Updated: 2026-05-14 Status: Active Service: apps/gateway

Step-by-step guide for working on the Gateway: local development, adding new services, understanding internals, and deploying.


1. Prerequisites

Requirement Version Notes
Node.js ≥ 18 Workers uses the V8 isolate, but Wrangler CLI runs in Node
pnpm ≥ 9 Workspace package manager
Wrangler CLI v4 (bundled in devDependencies) Run via pnpm exec wrangler or workspace scripts
Cloudflare account Required for KV namespace IDs; not needed for pure local dev

2. Local Development Setup

2.1 Environment Variables

Create apps/gateway/.env (gitignored). This file is loaded by wrangler dev --env-file=.env.

# apps/gateway/.env
JWT_PUBLIC_KEY="-----BEGIN PUBLIC KEY-----
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA...
-----END PUBLIC KEY-----"
 
GATEWAY_SECRET="dev-shared-secret-change-in-prod"
DATABASE_URL="postgresql://user:pass@ep-xxx.neon.tech/neondb?sslmode=require"
DEBUG="true"

IMPORTANT

Never commit .env or .dev.vars. These contain live secrets. Wrangler will use the local KV simulator regardless of the KV namespace IDs in wrangler.toml — you don't need real Cloudflare credentials to develop locally.

2.2 Starting the Gateway

From the workspace root:

pnpm dev

Or from the gateway package directly:

cd apps/gateway
pnpm dev

This runs wrangler dev --env-file=.env on port 8788.

2.3 Running Alongside Other Services

All Workers in the monorepo must be running for Service Bindings to resolve. In Wrangler v4, wrangler dev with Service Bindings will auto-connect to other locally-running workers on the same machine.

Start every service you need:

# Terminal 1 — gateway
cd apps/gateway && pnpm dev
 
# Terminal 2 — manager (provides auth)
cd apps/manager && pnpm dev
 
# Terminal 3 — blog service
cd apps/blog-service && pnpm dev

Or use the Turborepo dev pipeline from root which starts all services in parallel.

2.4 Verifying the Gateway is Running

curl http://localhost:8788/health

Expected response:

{ "status": "ok", "env": { "BLOG_SERVICE": true, ... } }

If any binding shows false, that downstream Worker is not running.


3. Making Authenticated Requests Locally

3.1 With a JWT

Obtain an access token from the manager service (login endpoint):

curl -X POST http://localhost:8787/auth/login \
  -H "Content-Type: application/json" \
  -d '{ "email": "you@example.com", "password": "..." }'

Use the returned accessToken:

curl http://localhost:8788/blog/admin/posts \
  -H "Authorization: Bearer eyJhbGci..."

3.2 With an API Key

Create an API key in the manager UI or directly in the database. Then:

curl http://localhost:8788/blog/public/posts \
  -H "x-api-key: sk_test_xxxxx"

4. Adding a New Downstream Service

Follow these six steps every time a new Cloudflare Worker needs to be proxied through the Gateway.

Step 1 — Add the Service Binding in wrangler.toml

# apps/gateway/wrangler.toml
[[services]]
binding = "MY_NEW_SERVICE"
service = "logicspike-my-new-service"

Step 2 — Add the Binding to GatewayBindings

// apps/gateway/src/types.ts
export type GatewayBindings = {
    // ... existing bindings ...
    MY_NEW_SERVICE: Fetcher
}

Step 3 — Create the Route Proxy File

Create apps/gateway/src/routes/my-new.proxy.ts. Use the pattern below — copy the simplest existing proxy (e.g. brain.proxy.ts) and adapt:

// apps/gateway/src/routes/my-new.proxy.ts
 
import { Hono } from "hono"
import { authMiddleware } from "../middleware/auth.middleware"
import { accessMiddleware } from "../middleware/access.middleware"
import { subscriptionGuard } from "../middleware/subscription.middleware"
import { logError } from "../utils/logger"
import { buildDownstreamHeaders, proxyFetch, proxyResponseArgs } from "../utils/proxy.utils"
import type { GatewayBindings } from "../types"
 
export const myNewProxy = new Hono<{ Bindings: GatewayBindings }>()
 
// Apply the middleware stack appropriate for this service:
myNewProxy.use("/*", authMiddleware)
myNewProxy.use("/*", accessMiddleware("my-new-service-code"))
myNewProxy.use("/*", subscriptionGuard)
 
myNewProxy.all("/*", async (c) => {
    const MY_NEW_SERVICE = c.env.MY_NEW_SERVICE
    if (!MY_NEW_SERVICE) {
        return c.json({ error: "Configuration error: MY_NEW_SERVICE binding missing" }, 500)
    }
 
    const { headers, targetUrl } = buildDownstreamHeaders(c, "/my-new")
 
    try {
        const response = await proxyFetch(MY_NEW_SERVICE, targetUrl.toString(), c.req.method, headers, c.req.raw.body)
        const [body, status, responseHeaders] = proxyResponseArgs(response)
 
        if (!response.ok) {
            logError(c, {
                message: `My New Service Error: ${response.status}`,
                status: response.status,
                error: { url: targetUrl.toString() },
            })
        }
 
        return c.newResponse(body, status, responseHeaders)
    } catch (error) {
        logError(c, {
            message: "Gateway failed to connect to my-new service",
            status: 502,
            error,
        })
        return c.json({ error: "Gateway failed to connect to my-new service" }, 502)
    }
})

Step 4 — Mount the Route in index.ts

// apps/gateway/src/index.ts
import { myNewProxy } from "./routes/my-new.proxy"
 
// ...
app.route("/my-new", myNewProxy)
// apps/gateway/src/index.ts — inside app.get("/health", ...)
MY_NEW_SERVICE: !!c.env.MY_NEW_SERVICE,

Step 6 — Register ServiceCode in @repo/core-types

The accessMiddleware uses a ServiceCode type from @repo/core-types. If your new service needs PBAC access control, add its code there and create the corresponding ServiceAccess entitlement in the manager service.


5. Adding a Public (API-Key) Route to an Existing Service

If a service needs a public-facing door (like blog's /public/*), follow this pattern:

// In your proxy file:
 
// 1. Apply API-key auth to the public sub-path
myProxy.use("/public/*", apiKeyMiddleware("my-service"))
 
// 2. Forward all public requests
myProxy.all("/public/*", async (c) => { /* ... same proxyFetch pattern */ })
 
// 3. Admin door (existing)
myProxy.use("/admin/*", authMiddleware)
myProxy.use("/admin/*", accessMiddleware("my-service"))
myProxy.use("/admin/*", subscriptionGuard)
myProxy.all("/admin/*", async (c) => { /* ... */ })

Then add open CORS for the public sub-path in index.ts:

app.use("/my-new/public/*", cors({
    origin: "*",
    allowMethods: ["GET", "OPTIONS"],
    allowHeaders: ["Authorization", "Content-Type", "x-api-key"],
    maxAge: 86400,
}))

6. Understanding proxy.utils.ts

Three functions handle all the low-level proxy plumbing.

6.1 buildDownstreamHeaders(c, prefix)

Builds the Headers object and targetUrl for the downstream request.

  • Strips prefix from the start of the path (anchored — won't mangle paths containing the prefix mid-string).
  • Deletes the host header (prevents downstream host-header rejection).
  • Sets x-gateway-key from c.env.GATEWAY_SECRET.
  • Injects identity headers from ctx.auth if present.

6.2 proxyFetch(service, url, method, headers, body)

Calls service.fetch() with the correct duplex: "half" option for streaming request bodies. This option is required by the Fetch spec for non-GET requests but is absent from the DOM RequestInit TypeScript types — the function handles the type cast internally so callers don't need @ts-ignore.

6.3 proxyResponseArgs(response)

Converts a Response into the three-tuple [body, status, headers] that Hono's c.newResponse() accepts. The body is the raw ReadableStream — no buffering occurs. This is what makes SSE streaming work end-to-end.


7. Logging

File: src/utils/logger.ts

The logger emits structured JSON to console.log / console.error. Cloudflare Workers Logs (or any log drain) picks this up.

// Convenience wrappers:
await logInfo(c, { message: "Post published", status: 200 })
await logError(c, { message: "Upstream returned 500", error, status: 502 })

Every log entry includes:

{
  "level": "ERROR",
  "message": "Blog Service Error: 500 Internal Server Error",
  "timestamp": "2026-05-14T10:23:00.000Z",
  "status": 500,
  "path": "/blog/admin/posts",
  "method": "POST",
  "error": { "message": "...", "stack": "..." }
}

Trace logging (every request) is gated on DEBUG === "true" to avoid burning log budget in production:

app.use("*", async (c, next) => {
    if (c.env.DEBUG === "true") {
        console.log(`[Gateway] ${c.req.method} ${c.req.url}`)
    }
    await next()
})

8. Type System

8.1 GatewayBindings

src/types.ts declares all Cloudflare bindings as TypeScript types. Every Hono instance is parameterized with Hono<{ Bindings: GatewayBindings }> which gives type-safe access to c.env.*.

8.2 ContextVariableMap

src/context.ts augments Hono's ContextVariableMap interface so c.get("context") and c.set("context", ...) are fully typed:

declare module "hono" {
    interface ContextVariableMap {
        context: RequestContext  // from @repo/core-types
    }
}

Without this augmentation, c.get("context") would return unknown.


9. Deployment

9.1 Deploy to Production

cd apps/gateway
pnpm deploy

This runs wrangler deploy --minify. Wrangler bundles the Worker, tree-shakes, minifies, and uploads to Cloudflare. The Worker is deployed globally across all Cloudflare PoPs.

9.2 Setting Secrets

Secrets are never in wrangler.toml. Set them via:

wrangler secret put JWT_PUBLIC_KEY
wrangler secret put GATEWAY_SECRET
wrangler secret put DATABASE_URL

Wrangler will prompt for the value interactively.

9.3 Creating the KV Namespace (first deploy only)

wrangler kv namespace create RATE_LIMIT_KV
wrangler kv namespace create RATE_LIMIT_KV --preview

Copy the id and preview_id values printed by Wrangler into wrangler.toml:

[[kv_namespaces]]
binding = "RATE_LIMIT_KV"
id = "<id from wrangler output>"
preview_id = "<preview_id from wrangler output>"

9.4 Type Checking

pnpm check-types

Runs tsc --noEmit. Fix all errors before deploying — Wrangler will still deploy even with type errors, but the type check should be part of CI.


10. Common Issues and Fixes

Binding shows false in /health

The downstream Worker is not running locally. Start it in a separate terminal.

502 Gateway failed to connect to X service

Either:

  • The Service Binding name in wrangler.toml doesn't match the Worker's name in that Worker's wrangler.toml.
  • The downstream Worker crashed on startup (check its terminal).

401 Invalid token even with a valid-looking JWT

The JWT_PUBLIC_KEY in .env doesn't match the private key the manager used to sign the token. Re-fetch the public key from the manager's /auth/jwks.json endpoint or regenerate the key pair.

Rate limit firing in dev at low request counts

The in-memory store resets on every Wrangler hot-reload. You're counting requests across reloads incorrectly. Bind RATE_LIMIT_KV with the preview ID for persistent local rate-limit state.

Cannot read properties of undefined (reading 'auth') in access middleware

The accessMiddleware runs after authMiddleware and reads ctx.auth. If auth failed and returned early, accessMiddleware should never fire. If you see this, a route is applying accessMiddleware without authMiddleware before it.


11. Security Notes

  1. Never add public paths to manager.proxy.ts using startsWith — always use exact matching (PUBLIC_PATHS.has(rel)) to prevent path-traversal bypasses.

  2. Never put secrets in wrangler.toml [vars] — those are plaintext in the Cloudflare dashboard. Use wrangler secret put for everything sensitive.

  3. The x-gateway-key is the only trust anchor downstream services have. Downstream workers must reject requests without it. Never expose a downstream worker's URL publicly.

  4. Publishable keys in URLs — query-param key lookup (?api_key=) is safe only for publishable keys (read-only, domain-locked). Never use secret keys in URLs — they appear in logs and browser history.

  5. The 5-minute API-key cache means a revoked key stays valid for up to 5 minutes. If you need instant revocation (e.g. a suspected compromise), flush the KV cache entry manually: wrangler kv key delete ak:<sha256-hash> --binding=RATE_LIMIT_KV.


gateway