Skip to content

Contrato de la Capa HTTP y Observabilidad (FOUNDATION_HTTP_AND_OBSERVABILITY.md)

Estado: Contrato de infraestructura y presentación normativo para ninaku-app-api.

Este documento especifica el comportamiento, formato de respuestas, validación, revalidación condicional, paginación, manejo de errores, idempotencia y observabilidad para toda la superficie de API HTTP del backend de Ninaku.

Cualquier endpoint expuesto por un módulo de negocio debe cumplir de forma innegociable con las reglas de esta especificación.


1. Sobres Canónicos de Respuesta

Todas las respuestas exitosas (estados HTTP 200 OK y 201 Created) deben retornar su carga útil envuelta en un sobre JSON estandarizado. Queda prohibido retornar arreglos o JSONs primarios sin sobre.

A. Sobre Básico de Recurso Único

Se utiliza para operaciones que retornan un único elemento o entidad (ej. obtener un perfil, crear un registro).

json
{
  "data": {
    "id": "01920d3a-4f51-7b8c-a912-3e4f5a6b7c8d",
    "name": "Sucursal Centro",
    "code": "SUC-001",
    "status": "active",
    "createdAt": "2026-09-21T08:30:00.000Z"
  }
}

B. Sobre Enriquecido con Metadatos ({ data, meta })

Se utiliza cuando la respuesta requiere información contextual adicional, tal como paginación por cursores, métricas de ejecución o banderas de estado.

json
{
  "data": [
    {
      "id": "01920d3a-4f51-7b8c-a912-3e4f5a6b7c8d",
      "name": "Sucursal Centro"
    }
  ],
  "meta": {
    "pageInfo": {
      "hasNextPage": true,
      "hasPreviousPage": false,
      "startCursor": "eyJpZCI6IjAxOTIwZDNhLT...I3NjUzOTAwMH0.a8f9c...",
      "endCursor": "eyJpZCI6IjAxOTIwZDNiLTI...I3NjU0MDEwMH0.b1e2f..."
    },
    "requestId": "req_018f3a2b4c5d6e7f8a9b0c1d2e3f4a5b",
    "timestamp": "2026-09-21T08:42:52.123Z"
  }
}

C. Contenido Obligatorio del Bloque meta

Cuando el objeto meta está presente en la respuesta, sus campos siguen esta estructura:

  • requestId (string, obligatorio): Identificador único de la petición HTTP asignado por el Gateway o el Middleware de trazabilidad (X-Request-ID).
  • timestamp (string, obligatorio): Fecha y hora de generación de la respuesta en formato ISO 8601 estricto con precisión de milisegundos en UTC (YYYY-MM-DDTHH:mm:ss.sssZ).
  • pageInfo (object, opcional): Presente en respuestas de colecciones paginadas (ver Sección 4).

2. Controladores Delgados (Thin Controllers)

Los controladores HTTP en NestJS actúan exclusivamente como adaptadores de entrada en la capa de presentación (presentation/controllers/). Su única responsabilidad es vincular el protocolo HTTP con los casos de uso de la aplicación.

Lo Que UN CONTROLADOR DEBE HACER:

  1. Declarar la ruta HTTP, verbo (GET, POST, PUT, DELETE), etiquetas Swagger (@ApiTags) y limitadores de tasa (@Throttle).
  2. Recibir la petición validada mediante los decoradores de esquema Zod (@bodySchema, @querySchema, @paramSchema, @headerSchema).
  3. Extraer el contexto de sesión o tenant inyectado por la capa de seguridad (@CurrentIdentity(), @TenantContext()).
  4. Invocar un solo método del caso de uso correspondiente en la capa de aplicación (useCase.execute(...)).
  5. Mapear la salida del caso de uso al DTO de respuesta usando un mapeador dedicado (toResponseDto(result)).
  6. Delegar cualquier excepción sin capturarla con try/catch para que fluya hacia el interceptor de fallos del módulo.

Lo Que UN CONTROLADOR NUNCA DEBE HACER:

  • Cero bloques try/catch: Prohibido usar try/catch imperativos dentro de los controladores. La traducción de errores la hace un NestInterceptor del módulo, en presentation/failures/.

    Por qué un interceptor y no un ExceptionFilter de módulo. ProblemDetailsFilter de Foundation está registrado globalmente con APP_FILTER, es @Catch() y escribe la respuesta con httpAdapter.reply; nunca relanza. Un filtro de módulo corre antes que él y es terminal: si hace throw dentro de catch(), la excepción no pasa a otro filtro — se escapa del framework y todo fallo de dominio contesta 500 en vez de su 401, 409 o 422. El error lanzado desde un interceptor sí viaja por el camino normal hasta ese filtro global.

  • Cero lógica de negocio: No se deben realizar cálculos, comparaciones de estado ni validaciones de dominio dentro del controlador.

  • Cero consultas SQL ni acceso a la base de datos: Un controlador nunca inyecta repositorios, clientes pg ni Kysely.

  • Cero transformaciones ad-hoc: Las estructuras de salida se construyen mediante funciones de mapeo puras y probadas.

ts
// ✅ CONTROLADOR CANÓNICO Y DELGADO
@ApiTags('Outlets')
@Controller('outlets')
@UseInterceptors(OutletsFailureInterceptor)
export class OutletsController {
  constructor(private readonly createOutlet: CreateOutletUseCase) {}

  @Post()
  @HttpCode(HttpStatus.CREATED)
  @Throttle({ default: { limit: 20, ttl: 60000 } })
  @ApiOperation({ summary: 'Registrar un nuevo punto de venta' })
  @ApiDataResponse({ name: 'Outlet', schema: OutletResponseSchema, status: HttpStatus.CREATED })
  async create(
    @bodySchema(CreateOutletRequestSchema) body: CreateOutletRequestInput,
    @TenantContext() tenant: TenantContextInput,
  ): Promise<OutletResponse> {
    const result = await this.createOutlet.execute({
      ...body,
      organizationId: tenant.organizationId,
      businessUnitId: tenant.businessUnitId,
    });

    return toOutletResponse(result);
  }
}

3. Revalidación por ETags (Caché Condicional)

Para optimizar el ancho de banda y prevenir colisiones por ediciones concurrentes (lost updates), la API implementa el mecanismo de encabezados de validación HTTP/1.1 (RFC 9110) mediante ETags (Entity Tags).

A. Lecturas Condicionales (If-None-Match \(\rightarrow\) 304 Not Modified)

  1. Generación del ETag: En los endpoints de lectura (GET), el servidor calcula un hash criptográfico (SHA-256 truncado a 16 caracteres hexadecimales) basado en el ID y la versión o marca de tiempo de modificación del recurso.
  2. Encabezado en Respuesta: El servidor envía el encabezado ETag: W/"v2-a8f9c1d2e3f4b5a6".
  3. Petición del Cliente: En lecturas subsecuentes, el cliente incluye el encabezado If-None-Match: W/"v2-a8f9c1d2e3f4b5a6".
  4. Respuesta 304 Not Modified: Si el ETag generado en el servidor coincide exactamente con el enviado por el cliente, la API responde un estado 304 Not Modified con el cuerpo completamente vacío (cero bytes).
http
HTTP/1.1 304 Not Modified
ETag: W/"v2-a8f9c1d2e3f4b5a6"
Date: Mon, 21 Sep 2026 08:45:00 GMT
X-Request-ID: req_018f3a2b4c5d6e7f8a9b0c1d2e3f4a5b

B. Escrituras Concurrencia Controlada (If-Match \(\rightarrow\) 412 Precondition Failed)

  1. En operaciones de actualización (PUT, PATCH) o eliminación (DELETE), el servidor puede exigir que el cliente envíe el encabezado If-Match: "v2-a8f9c1d2e3f4b5a6".
  2. Si el recurso ha sido modificado por otra transacción en la base de datos (haciendo que el ETag actual sea diferente), la API rechaza la operación inmediatamente con estado 412 Precondition Failed.
json
{
  "type": "https://api.ninaku.io/problems/foundation/precondition_failed",
  "title": "Precondition Failed",
  "status": 412,
  "detail": "El recurso ha sido modificado por otra sesión. Por favor, obtenga la versión más reciente antes de reintentar.",
  "instance": "/api/v1/outlets/SUC-001",
  "code": "foundation.precondition_failed",
  "traceId": "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01"
}

4. Paginación por Cursores Opacos Firmados con HMAC

Queda estrictamente prohibido el uso de OFFSET/LIMIT en las consultas de base de datos y endpoints de API del proyecto.

¿Por Qué NUNCA OFFSET?

  1. Degradación de Rendimiento \(O(N)\): Con OFFSET 100000, PostgreSQL debe escanear y descartar físicamente 100,000 filas antes de retornar las siguientes 50, incrementando el I/O y la latencia.
  2. Inconsistencia de Datos (Inconsistent Reads): Si se insertan o eliminan registros mientras el usuario navega entre páginas, los elementos se desplazan, provocando que el cliente vea registros duplicados o se salte registros sin saberlo.

A. Estructura de la Paginación por Keyset

La paginación en Ninaku se basa en Keyset Pagination utilizando como clave de ordenamiento primario (created_at DESC, id DESC).

El cursor devuelto al cliente es un cursor opaco generado como un string codificado en Base64URL que contiene el estado del puntero junto con una firma criptográfica HMAC-SHA256 para evitar que el cliente altere los valores de paginación.

Estructura Interna del Payload del Cursor (antes de codificar):

json
{
  "v": 1,
  "t": "2026-09-21T08:30:00.000Z",
  "id": "01920d3a-4f51-7b8c-a912-3e4f5a6b7c8d",
  "exp": 1790000000
}

Firma HMAC:

El cursor se firma usando la clave de entorno CURSOR_SIGNING_KEY. Si el cliente modifica la fecha o el ID del cursor para saltarse registros, la verificación de la firma falla y la API retorna un error 400 Bad Request (collection.cursor_tampered).

B. El Bloque meta.pageInfo

Toda respuesta de colección paginada retorna la información de navegación dentro del objeto meta.pageInfo:

json
"pageInfo": {
  "hasNextPage": true,
  "hasPreviousPage": false,
  "startCursor": "eyJ2IjoxLCJ0IjoiMjAyNi0wOS0yMVQwODozMDowMC4wMDBaIiwiaWQiOiIwMTkyMGQzYS00ZjUxLTdiOGMtYTkxMi0zZTRmNWE2YjdjOGQiLCJleHAiOjE3OTAwMDAwMDB9.s8a7f6d5...",
  "endCursor": "eyJ2IjoxLCJ0IjoiMjAyNi0wOS0yMVQwODozNTowMC4wMDBaIiwiaWQiOiIwMTkyMGQzYi01ZjUyLThiOWQtYjAxMy00ZTVmNmE3YjhjOWUiLCJleHAiOjE3OTAwMDAwMDB9.k9b8a7c6..."
}

C. Parámetros HTTP de Consulta Paginada

  • limit (integer, opcional): Cantidad de registros solicitados. Valor por defecto: 50. Límite máximo infranqueable: 100.
  • after (string, opcional): Cursor opaco para obtener los registros posteriores a endCursor.
  • before (string, opcional): Cursor opaco para navegación hacia atrás.

5. Errores RFC 9457 Problem Details

Toda respuesta de error HTTP (códigos \(4xx\) y \(5xx\)) debe retornar obligatoriamente el formato estandarizado RFC 9457 (Problem Details for HTTP APIs) utilizando el tipo de contenido application/problem+json.

A. Estructura Exacta del Cuerpo de Error

json
{
  "type": "https://api.ninaku.io/problems/identity/sign_in_refused",
  "title": "Authentication Refused",
  "status": 401,
  "detail": "Las credenciales ingresadas son incorrectas o la cuenta se encuentra suspendida.",
  "instance": "/api/v1/sign-ins",
  "code": "identity.sign_in_refused",
  "traceId": "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01"
}

B. Definición de Campos RFC 9457

  • type (string, URI): Enlace único a la documentación del problema. Sigue el formato https://api.ninaku.io/problems/<modulo>/<codigo_slug>.
  • title (string): Resumen corto del tipo de error en inglés para desarrolladores.
  • status (integer): Código de estado HTTP de la respuesta (ej. 400, 401, 403, 404, 409, 422, 500).
  • detail (string): Explicación humana detallada de la causa específica del fallo en el idioma de la petición.
  • instance (string): Ruta de la URI del endpoint que originó la petición (request.url).
  • code (string, EL CONTRATO MÁQUINA): Código estandarizado en notación de puntos <modulo>.<razon>.
  • traceId (string): Identificador de traza W3C Trace Context (traceparent) para correlacionar el error en el sistema de observabilidad.

C. La Regla de Oro: El Código es el Contrato

El campo code es la ÚNICA garantía contractual para el cliente cliente/frontend. El texto en el campo detail es meramente una ayuda de depuración y puede cambiar o traducirse sin previo aviso. Los clientes de la API deben reaccionar programáticamente evaluando exclusivamente el valor de code.

Ejemplo de Errores de Validación de Entrada (400 Bad Request):

Cuando la validación de esquemas Zod falla, se incluye el arreglo errors dentro del objeto extendido:

json
{
  "type": "https://api.ninaku.io/problems/foundation/validation_failed",
  "title": "Validation Failed",
  "status": 400,
  "detail": "La petición contiene campos no válidos o faltantes.",
  "instance": "/api/v1/sign-ups",
  "code": "foundation.validation_failed",
  "traceId": "00-8cd91e234f567a8b9c0d1e2f3a4b5c6d-11e2f3a4b5c6d7e8-01",
  "meta": {
    "errors": [
      {
        "field": "email",
        "message": "Formato de correo electrónico no válido."
      },
      {
        "field": "password",
        "message": "La contraseña debe tener al menos 12 caracteres."
      }
    ]
  }
}

6. Validación con Zod en la Frontera HTTP

Toda entrada proveniente de la red debe ser saneada y validada estrictamente en la frontera del controlador antes de alcanzar la capa de aplicación o dominio.

A. Decoradores de Validación

Ninaku provee decoradores de parámetros con soporte de inferencia de tipos basados en esquemas Zod:

  1. @bodySchema(Schema): Valida el cuerpo JSON de la petición (req.body).
  2. @querySchema(Schema): Valida los parámetros de consulta URL (req.query).
  3. @paramSchema(Schema): Valida los parámetros de ruta (req.params).
  4. @headerSchema(Schema): Valida los encabezados HTTP requeridos (req.headers).

B. Reglas de Validación de Esquemas Zod

  • Estrictez Obligatoria (.strict()): Todos los esquemas Zod de entrada deben declarar .strict() para rechazar automáticamente cualquier campo no reconocido enviado en el JSON, evitando ataques de contaminación de propiedades (mass assignment).
  • Tipado Derivado Cero-Duplicación: Queda prohibido declarar interfaces TypeScript manuales para las peticiones. Los tipos se derivan usando z.infer<typeof Schema>:
ts
export const CreateOutletRequestSchema = z.object({
  name: z.string().min(3).max(100),
  code: z.string().regex(/^[A-Z0-9_-]{3,20}$/),
  address: z.string().min(5).max(250),
}).strict();

export type CreateOutletRequestInput = z.infer<typeof CreateOutletRequestSchema>;

7. Idempotencia en Operaciones Mutables

Para prevenir la ejecución duplicada de comandos no idempotentes (ej. procesar un pago, emitir una factura, registrar una venta) ante reintentos de red o fallos de conexión, la API implementa el patrón Idempotency-Key.

A. Flujo de Trabajo con Idempotency-Key

  1. El cliente envía un encabezado Idempotency-Key: 9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d (UUID v4 o v7) en peticiones de mutación. La clave se evalúa siempre junto con el organizationId del tenant activo, de modo que dos organizaciones pueden reutilizar el mismo valor de clave sin interferir entre sí.
  2. Reserva del Claim en la tabla propietaria del módulo: No existe una tabla universal de claims ni una función de PostgreSQL dedicada a la idempotencia. Cada módulo reserva el claim insertando la fila en su propia tabla de negocio (ej. orders) dentro de la transacción inicial, protegida por una restricción UNIQUE (organization_id, idempotency_key) sobre esa misma tabla, mediante INSERT ... ON CONFLICT (organization_id, idempotency_key) DO NOTHING RETURNING .... Si la inserción retorna una fila, la clave es nueva y la petición continúa su ejecución normal.
  3. Reconocimiento de un reintento: Si la inserción no retorna fila porque la clave ya fue reservada, el módulo recupera la fila existente y la compara contra la intención de la petición actual mediante su propia función de comparación (ej. el monto normalizado de un pedido). El orquestador genérico de este flujo, resolveIdempotentClaim (src/foundation/idempotency/resolve-idempotent-claim.ts), no conoce SQL ni el esquema del módulo: solo recibe claim, loadExisting y matchesIntent como funciones provistas por quien lo invoca.
  4. Replay: Si la fila existente coincide con la intención de la petición, la API omite la ejecución del caso de uso, retorna la respuesta persistida con el mismo estado HTTP original y agrega el encabezado Idempotent-Replayed: true. La primera ejecución de una clave no incluye este encabezado.
  5. Clave reutilizada con otra intención: Si la fila existente no coincide con la intención de la petición actual, la API responde 409 Conflict con código idempotency.key_reused (retryable: false), sin exponer el cuerpo ni el identificador de la petición original almacenada.
  6. Reserva concurrente en curso: Si una segunda petición con la misma clave llega mientras la primera transacción todavía no confirmó (COMMIT), el INSERT queda bloqueado por el candado de la restricción única hasta que la primera transacción termine. Si ese bloqueo excede el lock_timeout de la sesión, la API responde 409 Conflict con código idempotency.in_progress (retryable: true) y el encabezado Retry-After: 1.
http
HTTP/1.1 200 OK
Content-Type: application/json
Idempotency-Key: 9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d
Idempotent-Replayed: true
X-Request-ID: req_018f3a2b4c5d6e7f8a9b0c1d2e3f4a5b

8. Observabilidad, Trazas y Telemetría

La observabilidad de Ninaku Core está alineada con las especificaciones de la Cloud Native Computing Foundation (CNCF) y OpenTelemetry.

A. Correlación de Peticiones y Trazas W3C

  • traceparent (W3C Trace Context): La API acepta e inyecta encabezados de traza estándar W3C:
    traceparent: 00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01.
  • Identificador de Petición (X-Request-ID): Cada petición que ingresa al sistema recibe un X-Request-ID generado por el servidor (UUID v4) que se propaga a través del contexto asíncrono (AsyncLocalStorage) en todos los logs, consultas SQL y llamadas a colas. El middleware ignora cualquier valor de X-Request-ID enviado por el cliente y genera uno nuevo en cada petición: un cliente no puede correlacionar sus propios registros proporcionando este encabezado en la petición.

B. Registros Estructurados y Redacción Automática de Secretos

Los logs se emiten en formato JSON estructurado listo para ser ingeridos por colectores OpenTelemetry / Vector / Datadog.

Redacción Obligatoria de Datos Sensibles:

El logger redacta automáticamente en el origen cualquier propiedad coincidente con los patrones de secretos o PII:

  • password, passwd, pwd, secret, token (incluye variantes compuestas como accessToken o refreshToken), authorization, cookie, dsn, credential, certificate, otp, pin
  • Palabras compuestas: apiKey, signingKey, connectionString, privateKey
json
{
  "level": "info",
  "time": "2026-09-21T08:50:00.123Z",
  "pid": 1234,
  "hostname": "api-pod-8a9b",
  "reqId": "req_018f3a2b4c5d6e7f8a9b0c1d2e3f4a5b",
  "traceId": "4bf92f3577b34da6a3ce929d0e0e4736",
  "spanId": "00f067aa0ba902b7",
  "organizationId": "01920d3a-4f51-7b8c-a912-3e4f5a6b7c8d",
  "msg": "Petición HTTP completada",
  "http": {
    "method": "POST",
    "url": "/api/v1/sign-ins",
    "status": 201,
    "responseTimeMs": 12.4
  }
}

C. Métricas OpenTelemetry

La aplicación expone métricas en formato OTLP / Prometheus para monitorear el estado del servicio:

  • http.server.duration (Histograma de latencia por ruta, verbo y estado HTTP).
  • db.client.connections.usage (Conexiones activas e inactivas en el pool de PostgreSQL).
  • db.client.queries.duration (Duración de ejecución de sentencias SQL).

9. Medición de Latencia y Umbrales SLA Mandatorios

El rendimiento del backend se evalúa estrictamente bajo percentiles en escenarios de carga pico representativos.

Tabla de Umbrales de Service Level Agreement (SLA)

Percentil de LatenciaUmbral Máximo PermitidoAcción si se Incumple
P50 (Mediana)< 15 msAceptación normal del sistema.
P90< 35 msAlerta temprana de degradación en el tablero de rendimiento.
P95< 50 msLímite máximo de aceptación para despliegues en CI/CD.
P99< 100 msBloqueo automático del release hasta optimizar consultas o índices.

Cualquier cambio de código que degrade el P95 por encima de los 50 ms en los benchmarks automatizados (npm run perf:baseline) hará fallar las pruebas de CI.


10. Versionado de API y Generación de OpenAPI

A. Estrategia de Versionado

  • Versionado en URI: La API utiliza versionado primario por ruta URL con el prefijo /api/v1/.
  • Inmutabilidad de V1: Los cambios rompedores (breaking changes) en contratos DTO existentes están prohibidos dentro de /v1/. Si una actualización requiere alterar tipos o eliminar campos, se creará el nuevo endpoint bajo /api/v2/.

B. Generación Automatizada del Documento OpenAPI

  • El documento OpenAPI 3.0 se genera programáticamente a partir de los decoradores de controlador y los esquemas Zod en el arranque del entorno de construcción.
  • Se compila en el archivo docs/generated/openapi.json.
  • Verificación Byte a Byte en CI: El script npm run openapi:check regenera la especificación OpenAPI en memoria y la compara byte a byte contra docs/generated/openapi.json. Si un desarrollador altera un DTO o controlador sin comitear la actualización del archivo OpenAPI, el pipeline de CI fallará de inmediato.

11. Seguridad de Respuestas y Saneamiento de Datos

La API garantiza que bajo ninguna circunstancia se filtren detalles de la infraestructura interna hacia clientes externos.

A. Prohibiciones de Filtración de Información

Toda respuesta emitida por la API (especialmente en estados \(5xx\) o excepciones imprevistas) es filtrada por el ProblemDetailsFilter global para garantizar que:

  1. NUNCA exponga sintaxis ni errores de SQL: Prohibido retornar fragmentos como syntax error at or near... o SELECT * FROM....
  2. NUNCA exponga nombres de restricciones de BD: Prohibido exponer nombres de llaves primarias, foráneas o índices únicos (ej. ninaku_core.fk_outlets_org_id).
  3. NUNCA exponga Stack Traces: Las trazas de pila de ejecución de Node.js / TypeScript solo se registran en los logs internos del servidor y jamás viajan en el cuerpo HTTP público.
  4. NUNCA exponga detalles de conexión ni credenciales: Prohibido mostrar IPs internas, nombres de host de PostgreSQL, puertos o nombres de usuario de base de datos.
  5. NUNCA exponga datos de otros Tenants: Las consultas y respuestas están estrictamente aisladas mediante la política RLS en PostgreSQL y la validación del contexto de organización activo.

En caso de un error no controlado de servidor (\(500\)), la API retornará la siguiente respuesta genérica y segura:

json
{
  "type": "https://api.ninaku.io/problems/foundation/internal_error",
  "title": "Internal Server Error",
  "status": 500,
  "detail": "Ha ocurrido un error interno no esperado. Por favor, proporcione el traceId al equipo de soporte.",
  "instance": "/api/v1/outlets",
  "code": "foundation.internal_error",
  "traceId": "00-9f8e7d6c5b4a3f2e1d0c9b8a7f6e5d4c-3a2b1c0d9e8f7a6b-01"
}

12. Resumen de Verificación para Desarrolladores

Antes de enviar una Pull Request que afecte a la capa HTTP, verifica:

  1. ¿Tu respuesta usa el sobre { data } o { data, meta }?
  2. ¿Los esquemas Zod de entrada tienen .strict()?
  3. ¿El controlador carece de bloques try/catch y delega en @UseInterceptors(...)?
  4. ¿Los errores retornan la estructura RFC 9457 con su código estandarizado?
  5. ¿Ejecutaste npm run openapi:generate y npm run openapi:check para actualizar la documentación de la API?
  6. ¿El comando npm run verify pasa al 100% en verde?

13. Política CORS

La API deniega por defecto cualquier origen cross-origin. configureHttpApplication (src/bootstrap/configure-http-application.ts) llama a app.enableCors(...) con una lista de permitidos (origin) resuelta desde la variable de entorno CORS_ALLOWED_ORIGINS, sin credenciales (credentials: false, porque la autenticación viaja en Authorization: Bearer y no en cookies) y con las mismas cabeceras de petición y respuesta en todo entorno:

  • Cabeceras de petición permitidas: Content-Type, Authorization, If-Match, If-None-Match, Idempotency-Key, X-Elevation-Token.
  • Cabeceras de respuesta expuestas (Access-Control-Expose-Headers): ETag, X-Request-Id, Idempotent-Replayed, Retry-After. Sin esta lista, fetch/XMLHttpRequest desde un origen distinto puede recibir la respuesta pero no puede leer estas cabeceras desde JavaScript, aunque el servidor las haya enviado.

A. Formato de CORS_ALLOWED_ORIGINS

Lista de orígenes absolutos separados por comas (esquema y host, sin comodín, sin path, query ni fragment). corsAllowedOriginsFromString() (src/foundation/config/schema/environment-variables.schema.ts) recorta espacios, descarta entradas vacías (una coma sobrante no cuenta como origen) y rechaza en el arranque cualquier entrada que no sea un origen absoluto exacto. Ausente o vacía deniega todo origen cross-origin, incluido en desarrollo.

B. Quién declara el valor por entorno

CORS_ALLOWED_ORIGINS no es un secreto: es un valor público (el origen del sitio de documentación) declarado como literal en .railway/railway.ts, igual que NODE_ENV, y no con preserve(). El único consumidor previsto hoy es la referencia de API publicada (docs/api-reference.md), que necesita hacer peticiones reales desde el navegador contra la API del mismo entorno:

EntornoOrigen permitidoPor qué
staginghttps://docs-staging.ninaku.ioSitio de documentación de staging, con datos desechables
productionhttps://docs.ninaku.ioSitio de documentación de main; nunca apunta a la API de staging

Un origen de documentación de un entorno nunca se agrega a la lista permitida del otro: la documentación de producción jamás debe poder llamar a la API de staging ni viceversa. RAILWAY_OPERATIONS.md documenta el mecanismo de despliegue de esta variable.

C. Verificación

test/e2e/http-application-policy.e2e-spec.ts prueba contra una aplicación real: deniega por defecto, permite y refleja el origen exacto de la lista, responde el preflight con los métodos/cabeceras correctos, expone ETag, X-Request-Id y Retry-After a través de orígenes, y nunca envía Access-Control-Allow-Credentials.

Application Foundation in progress. Tracked in issue #13.