Appearance
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:
- Declarar la ruta HTTP, verbo (
GET,POST,PUT,DELETE), etiquetas Swagger (@ApiTags) y limitadores de tasa (@Throttle). - Recibir la petición validada mediante los decoradores de esquema Zod (
@bodySchema,@querySchema,@paramSchema,@headerSchema). - Extraer el contexto de sesión o tenant inyectado por la capa de seguridad (
@CurrentIdentity(),@TenantContext()). - Invocar un solo método del caso de uso correspondiente en la capa de aplicación (
useCase.execute(...)). - Mapear la salida del caso de uso al DTO de respuesta usando un mapeador dedicado (
toResponseDto(result)). - Delegar cualquier excepción sin capturarla con
try/catchpara que fluya hacia el interceptor de fallos del módulo.
Lo Que UN CONTROLADOR NUNCA DEBE HACER:
Cero bloques
try/catch: Prohibido usartry/catchimperativos dentro de los controladores. La traducción de errores la hace unNestInterceptordel módulo, enpresentation/failures/.Por qué un interceptor y no un
ExceptionFilterde módulo.ProblemDetailsFilterde Foundation está registrado globalmente conAPP_FILTER, es@Catch()y escribe la respuesta conhttpAdapter.reply; nunca relanza. Un filtro de módulo corre antes que él y es terminal: si hacethrowdentro decatch(), la excepción no pasa a otro filtro — se escapa del framework y todo fallo de dominio contesta500en vez de su401,409o422. 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
pgni 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)
- 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. - Encabezado en Respuesta: El servidor envía el encabezado
ETag: W/"v2-a8f9c1d2e3f4b5a6". - Petición del Cliente: En lecturas subsecuentes, el cliente incluye el encabezado
If-None-Match: W/"v2-a8f9c1d2e3f4b5a6". - Respuesta
304 Not Modified: Si el ETag generado en el servidor coincide exactamente con el enviado por el cliente, la API responde un estado304 Not Modifiedcon 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_018f3a2b4c5d6e7f8a9b0c1d2e3f4a5bB. Escrituras Concurrencia Controlada (If-Match \(\rightarrow\) 412 Precondition Failed)
- En operaciones de actualización (
PUT,PATCH) o eliminación (DELETE), el servidor puede exigir que el cliente envíe el encabezadoIf-Match: "v2-a8f9c1d2e3f4b5a6". - 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?
- 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. - 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 aendCursor.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 formatohttps://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:
@bodySchema(Schema): Valida el cuerpo JSON de la petición (req.body).@querySchema(Schema): Valida los parámetros de consulta URL (req.query).@paramSchema(Schema): Valida los parámetros de ruta (req.params).@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
- 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 elorganizationIddel tenant activo, de modo que dos organizaciones pueden reutilizar el mismo valor de clave sin interferir entre sí. - 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ónUNIQUE (organization_id, idempotency_key)sobre esa misma tabla, medianteINSERT ... 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. - 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 recibeclaim,loadExistingymatchesIntentcomo funciones provistas por quien lo invoca. - 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. - 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 Conflictcon códigoidempotency.key_reused(retryable: false), sin exponer el cuerpo ni el identificador de la petición original almacenada. - Reserva concurrente en curso: Si una segunda petición con la misma clave llega mientras la primera transacción todavía no confirmó (
COMMIT), elINSERTqueda bloqueado por el candado de la restricción única hasta que la primera transacción termine. Si ese bloqueo excede ellock_timeoutde la sesión, la API responde409 Conflictcon códigoidempotency.in_progress(retryable: true) y el encabezadoRetry-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_018f3a2b4c5d6e7f8a9b0c1d2e3f4a5b8. 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 unX-Request-IDgenerado 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 deX-Request-IDenviado 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 comoaccessTokenorefreshToken),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 Latencia | Umbral Máximo Permitido | Acción si se Incumple |
|---|---|---|
| P50 (Mediana) | < 15 ms | Aceptación normal del sistema. |
| P90 | < 35 ms | Alerta temprana de degradación en el tablero de rendimiento. |
| P95 | < 50 ms | Límite máximo de aceptación para despliegues en CI/CD. |
| P99 | < 100 ms | Bloqueo 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:checkregenera la especificación OpenAPI en memoria y la compara byte a byte contradocs/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:
- NUNCA exponga sintaxis ni errores de SQL: Prohibido retornar fragmentos como
syntax error at or near...oSELECT * FROM.... - 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). - 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.
- 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.
- 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:
- ¿Tu respuesta usa el sobre
{ data }o{ data, meta }? - ¿Los esquemas Zod de entrada tienen
.strict()? - ¿El controlador carece de bloques
try/catchy delega en@UseInterceptors(...)? - ¿Los errores retornan la estructura RFC 9457 con su código estandarizado?
- ¿Ejecutaste
npm run openapi:generateynpm run openapi:checkpara actualizar la documentación de la API? - ¿El comando
npm run verifypasa 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/XMLHttpRequestdesde 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:
| Entorno | Origen permitido | Por qué |
|---|---|---|
staging | https://docs-staging.ninaku.io | Sitio de documentación de staging, con datos desechables |
production | https://docs.ninaku.io | Sitio 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.