ladder-gateway
ladder-gateway
Section titled “ladder-gateway”Servicio Gateway para los servicios de LadderDev.
Hono + Node · TypeScript estricto · Zod · arquitectura hexagonal.
Gateway — Documentación técnica
Section titled “Gateway — Documentación técnica”1. Qué es y qué no es
Section titled “1. Qué es y qué no es”El gateway es un proxy stateless: verifica acceso (JWT o ruta pública) y reenvía el request al upstream correcto. No tiene base de datos propia, no genera tokens, no guarda sesiones. Todo lo que necesita para decidir “dejar pasar o no” lo obtiene de:
- El JWT que trae el request (verificado contra el JWKS de
auth-service, en memoria). - La config de rutas (
routes.config.ts, código versionado). - El caché de feature-flags/kill-switch (refrescado por polling cada 10s contra
auth-service).
2. Topología — dominio público en paralelo, no en cadena
Section titled “2. Topología — dominio público en paralelo, no en cadena”Cloudflare ├── auth.ladderdev.com → auth-service (público, independiente) └── api.ladderdev.com → gateway (público, independiente) │ red interna Docker (sin puertos públicos) │ ladder-api · api-nueva-x · ...auth-service no está detrás del gateway — tiene su propio dominio público. El gateway solo le habla para leer el JWKS (clave pública de verificación), nunca reenvía tráfico de login/signup a través de sí mismo. Ver auth-service.md sección 2 para el porqué.
Las APIs de negocio (ladder-api, etc.) están solo en red interna, sin dominio público — se llega a ellas únicamente a través del gateway.
3. Descubrimiento de rutas — cada API declara su propia visibilidad
Section titled “3. Descubrimiento de rutas — cada API declara su propia visibilidad”Mantener a mano en el gateway qué rutas son públicas por cada API no escala: te vas a olvidar de actualizarlo, y en algún momento expones algo por accidente o bloqueas algo que debía ser público. La visibilidad vive junto a la definición de la ruta, en la propia API — el gateway la descubre, no la copias a mano.
Cada API expone un endpoint interno GET /__manifest (protegido con el mismo X-Gateway-Secret de la sección 5) que lista sus rutas con su visibilidad: public, protected o admin. El detalle de cómo se genera este manifiesto vive en auth-service.md/gateway.md compañero para APIs — ver el documento de implementación en cada API (route-registry.ts).
// gateway/src/routes.config.ts — ya no lleva publicPaths, solo el enrutamientoexport interface RouteConfig { prefix: string organizationId: string upstream: string}
export const routes: RouteConfig[] = [ { prefix: '/api/ladder', organizationId: 'ladder-api', upstream: 'http://ladder-api:3001' }, { prefix: '/api/nueva-x', organizationId: 'api-nueva-x', upstream: 'http://api-nueva-x:3002' }, // añadir API nueva = 1 entrada aquí, nada más]Caché del manifiesto por API (polling, igual patrón que los flags)
Section titled “Caché del manifiesto por API (polling, igual patrón que los flags)”type Visibility = 'public' | 'protected' | 'admin'interface ManifestEntry { method: string; path: string; visibility: Visibility }
const cache: Record<string, ManifestEntry[]> = {}
export async function refreshManifest(organizationId: string, upstream: string) { try { const res = await fetch(`${upstream}/__manifest`, { headers: { 'X-Gateway-Secret': process.env.GATEWAY_SHARED_SECRET! }, }) cache[organizationId] = await res.json() } catch { console.error(`no se pudo refrescar manifest de ${organizationId}, usando cache anterior`) }}
export function getVisibility(organizationId: string, method: string, path: string): Visibility { const entries = cache[organizationId] const entry = entries?.find(r => r.method === method && matchPath(r.path, path)) return entry?.visibility ?? 'protected' // 👈 fail-closed: si no está en el manifiesto, se protege}Fail-closed a propósito: si una ruta nueva se despliega y el gateway todavía no sincronizó el manifiesto, se trata como protected, nunca como public — mejor pedir login de más por unos segundos que exponer algo por accidente.
3.1 Caso aciky-backend — rutas exactas por consumo, no wildcard de prefijo
Section titled “3.1 Caso aciky-backend — rutas exactas por consumo, no wildcard de prefijo”aciky-backend no usa el patrón de prefijo-wildcard de la sección 3 ({ prefix, organizationId, upstream } en routes.config.ts). En su lugar, aciky-backend.routes.json (repo raíz) lista solo los endpoints exactos que aciky-frontend realmente consume (x-consumers: [web] en su contrato OpenAPI), y registerManifestRoutes() (src/infrastructure/http/manifest-routes.ts) registra un handler Hono por cada entrada — método + path, reutilizando checkEnabled/requireOrgAccess/proxyToUpstream sin modificarlos.
aciky-backend.routes.json está congelado en su snapshot actual. El script que lo sincronizaba (scripts/sync-gateway-routes.js, copiaba ../aciky-frontend/gateway-routes.json) se eliminó (2026-09-18) — no tenía fuente disponible para correr (ese archivo no vive committeado en aciky-frontend) y mantenerlo sin uso real era más churre que ayuda. Cualquier ruta nueva que aciky-frontend empiece a consumir de aciky-backend no se refleja acá hasta que se resuelva la migración de auth ([auth][018] en TASKS.md) y se re-arme el mecanismo de sync correctamente del lado que corresponda.
Advertencia operativa: aciky-backend no está integrado a ladder-auth-service — sigue con su propio /api/auth/login con sesión/MySQL propia. GET /__manifest ya está implementado del lado aciky-backend (cerrado 2026-09-15, [gateway][001] en su TASKS.md), pero clasifica public/protected/admin contra sus 4 middlewares propios (middleware/auth.js), no contra JWT de ladder-auth-service — el manifiesto resuelve visibilidad, no autenticación. Mientras el login siga separado, un JWT válido de ladder-auth-service no sirve para pasar rutas protected/admin de aciky-backend, y el lado gateway (agregar la entrada en routes.config.ts + enviar X-Gateway-Secret) tampoco está cableado todavía.
Decisión (2026-09-18): el camino elegido es migrar aciky-backend a auth centralizada (gateway-auth-centralizada.md §6), no mantener su sistema de visibilidad propio como estado final. Esa migración vive del lado de aciky-backend/aciky-frontend — cross-mundo, fuera de este repo. El cableado gateway (routes.config.ts + secret) queda pendiente hasta que esa migración cierre, para no exponer rutas que devolverían 401 indefinido con la auth actual. Ver [auth][018] en TASKS.md.
4. Middleware de auth — JWT + manifiesto + org matching
Section titled “4. Middleware de auth — JWT + manifiesto + org matching”npm install hono @hono/node-server joseimport type { MiddlewareHandler } from 'hono'import { jwtVerify, createRemoteJWKSet } from 'jose'import type { RouteConfig } from '../routes.config'import { getVisibility } from '../manifest-cache'
const JWKS = createRemoteJWKSet(new URL('https://auth.ladderdev.com/api/auth/jwks'))
export function requireOrgAccess(route: RouteConfig): MiddlewareHandler { return async (c, next) => { const relativePath = c.req.path.replace(route.prefix, '') || '/' const visibility = getVisibility(route.organizationId, c.req.method, relativePath)
if (visibility === 'public') { c.req.raw.headers.set('X-Org-Id', route.organizationId) return next() // pasa directo, sin exigir JWT — no hay X-User-Id }
const token = c.req.header('Authorization')?.replace('Bearer ', '') if (!token) return c.json({ error: 'unauthorized' }, 401)
let payload try { ;({ payload } = await jwtVerify(token, JWKS)) // verificación local, SIN DB } catch { return c.json({ error: 'invalid or expired token' }, 401) }
if (payload.activeOrganizationId !== route.organizationId) { return c.json({ error: 'forbidden: no access to this project' }, 403) }
if (visibility === 'admin' && payload.role !== 'admin' && payload.role !== 'owner') { return c.json({ error: 'forbidden: admin role required' }, 403) }
c.req.raw.headers.set('X-User-Id', payload.sub as string) c.req.raw.headers.set('X-User-Role', payload.role as string) c.req.raw.headers.set('X-Org-Id', route.organizationId) await next() }}5. Secreto compartido gateway↔API (defensa aunque estén en red interna)
Section titled “5. Secreto compartido gateway↔API (defensa aunque estén en red interna)”Confiar solo en “está en red interna” es frágil (mala config de Docker, contenedor comprometido, puerto expuesto por error). La API nunca debe confiar en X-User-Id solo porque llegó — debe venir firmado por el gateway con un secreto que solo ellos dos conocen.
Generación: un único valor random, el mismo en gateway + cada API downstream (hoy ladder-api, ladder-auth-service; cualquier upstream nuevo que se agregue a routes.config.ts lo necesita también).
openssl rand -hex 32Se guarda en Infisical, uno por proyecto (no hay referencia cross-proyecto en la edición Community self-hosted — se copia el mismo valor a mano en los 3). Rotación: sin auto-rotate (Enterprise-only), manual — ver [security] en TASKS.md.
// gateway — al reenviar, siempre añade el secretoc.req.raw.headers.set('X-Gateway-Secret', process.env.GATEWAY_SHARED_SECRET!)// cada API downstream — primero valida el secreto, ANTES de confiar en cualquier otro headerapp.use('*', async (c, next) => { if (c.req.header('X-Gateway-Secret') !== process.env.GATEWAY_SHARED_SECRET) { return c.json({ error: 'direct access not allowed' }, 403) } await next()})
app.use('*', async (c, next) => { const userId = c.req.header('X-User-Id') ?? null // null es válido para rutas públicas c.set('userId', userId) await next()})6. Kill switch por proyecto/módulo (sin Redis, vía Postgres de auth-service)
Section titled “6. Kill switch por proyecto/módulo (sin Redis, vía Postgres de auth-service)”let cache: Record<string, boolean> = {}
async function refresh() { try { const res = await fetch('https://auth.ladderdev.com/api/flags') cache = await res.json() } catch { console.error('no se pudo refrescar flags, usando cache anterior') }}
refresh()setInterval(refresh, 10_000)
export function isEnabled(key: string): boolean { return cache[key] !== false // habilitado por defecto si no existe la key}import type { MiddlewareHandler } from 'hono'import { isEnabled } from '../flags-cache'
export function checkEnabled(organizationId: string): MiddlewareHandler { return async (c, next) => { if (!isEnabled(`route:${organizationId}`)) { return c.json({ error: 'service temporarily disabled' }, 503) } await next() }}Apagar/prender sin redeploy: POST https://auth.ladderdev.com/api/flags con {"key":"route:cnm-cigars","enabled":false} (ver detalle en auth-service.md).
7. Resiliencia — timeout + circuit breaker por upstream
Section titled “7. Resiliencia — timeout + circuit breaker por upstream”const failures: Record<string, { count: number; openUntil: number }> = {}const THRESHOLD = 5const COOLDOWN_MS = 30_000
export function isCircuitOpen(upstream: string): boolean { const state = failures[upstream] return !!state && state.openUntil > Date.now()}export function recordFailure(upstream: string) { const state = failures[upstream] ?? { count: 0, openUntil: 0 } state.count++ if (state.count >= THRESHOLD) state.openUntil = Date.now() + COOLDOWN_MS failures[upstream] = state}export function recordSuccess(upstream: string) { delete failures[upstream]}import { proxy } from 'hono/proxy'import type { Context } from 'hono'import { isCircuitOpen, recordFailure, recordSuccess } from './circuit-breaker'
export async function proxyToUpstream(c: Context, upstream: string, prefix: string) { if (isCircuitOpen(upstream)) { return c.json({ error: 'upstream circuit open, try later' }, 503) } try { const res = await proxy(`${upstream}${c.req.path.replace(prefix, '')}`, c.req.raw, { signal: AbortSignal.timeout(5000), }) recordSuccess(upstream) return res } catch { recordFailure(upstream) return c.json({ error: 'upstream unavailable' }, 502) }}8. Servidor completo (gateway/src/index.ts)
Section titled “8. Servidor completo (gateway/src/index.ts)”import { Hono } from 'hono'import { serve } from '@hono/node-server'import { rateLimiter } from 'hono-rate-limiter'import { requireOrgAccess } from './middleware/auth'import { checkEnabled } from './middleware/kill-switch'import { proxyToUpstream } from './proxy-upstream'import { refreshManifest } from './manifest-cache'import { routes } from './routes.config'
const app = new Hono()
app.get('/health', (c) => c.json({ ok: true }))app.use('*', rateLimiter({ windowMs: 60_000, limit: 300 }))
for (const route of routes) { app.use(`${route.prefix}/*`, checkEnabled(route.organizationId)) app.use(`${route.prefix}/*`, requireOrgAccess(route)) app.all(`${route.prefix}/*`, (c) => proxyToUpstream(c, route.upstream, route.prefix))}
// refresca el manifiesto de cada API al arrancar y cada 10s (mismo patrón que los flags)async function refreshAllManifests() { await Promise.all(routes.map(r => refreshManifest(r.organizationId, r.upstream)))}refreshAllManifests()setInterval(refreshAllManifests, 10_000)
serve({ fetch: app.fetch, port: 3000 })9. Escalado — sí se pueden correr varias instancias
Section titled “9. Escalado — sí se pueden correr varias instancias”El gateway es stateless, así que replicar es trivial:
- Sin coordinación entre réplicas: verificar un JWT no depende de qué instancia lo procese.
- El único estado (caché de flags y de manifiestos) lo refresca cada réplica de forma independiente por su propio polling — convergen al mismo valor con ~10s de margen, no necesitan hablarse entre sí.
- En Coolify: configurar
replicas: Nen el servicio, balanceo automático vía Traefik detrás del dominio único. - El endpoint
/health(sección 8) permite que Coolify/Traefik saque de rotación una réplica caída.
Con pocas APIs y tráfico moderado, 1 sola instancia es suficiente — no dupliques prematuramente. El motivo real para pasar a 2+ réplicas incluso con poco tráfico es alta disponibilidad: el gateway es el único punto de entrada a todas las APIs, así que un crash de la única instancia tumba acceso a todo a la vez.
10. Variables de entorno
Section titled “10. Variables de entorno”GATEWAY_SHARED_SECRET= # compartido con todas las APIs downstream — generación en sección 5AUTH_SERVICE_URL=https://auth.ladderdev.comACIKY_BACKEND_UPSTREAM=http://aciky-backend:3000 # ver sección 3.1PORT=3000