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"
},
"meta": {}
}meta siempre está presente; si la ruta no declara metadatos estables, es un objeto vacío.
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..."
}
}
}C. Contenido del Bloque meta
El bloque meta de una respuesta exitosa lleva solo información estable: la que se deriva de la misma representación que data y no cambia si la misma petición se repite sin que nada cambie.
pageInfo(object): Presente solo en respuestas de colecciones paginadas (ver Sección 4); ninguna otra ruta lo declara.- Otros campos estables que declara cada ruta en su contrato OpenAPI (por ejemplo
truncatedoseats).
meta es cerrado. La regla, para toda ruta con sobre:
metasiempre está presente, aunque esté vacío.- Cada ruta documenta exactamente sus claves de
meta, todas requeridas, conadditionalProperties: false. Una ruta que no declara ninguna documenta{ "type": "object", "additionalProperties": false, "maxProperties": 0 }. El esquema compartidoEnvelopeno abremeta: deja que cada ruta lo cierre (ApiDataResponseyApiCollectionResponseensrc/foundation/openapi/api-envelope-response.decorator.ts). - Ningún encabezado se copia a
meta(ni el identificador de petición, ni la hora, ni el ETag). - Una colección paginada declara
pageInfoy nada de totales: contar filas cuesta en cada página y cambia con cada escritura, lo que rompería el ETag fuerte. Una lista acotada que nunca supera su tope no declaratruncated(por esoGET /catalog/categoriesyGET /catalog/modifier-groupsya no lo llevan: su tope es el mismo límite de creación). - El cuerpo real se valida contra el esquema documentado de rutas representativas en
test/e2e/envelope-meta-contract.e2e-spec.ts, que también comprueba que una clave no declarada se rechaza.
Dónde viven el identificador de petición y la hora. El cuerpo exitoso no lleva requestId ni timestamp. El identificador de la petición viaja siempre en el encabezado de respuesta X-Request-Id (lo fija RequestIdMiddleware en cada petición, antes de cualquier guard, y lo expone CORS) y la hora de la respuesta en el encabezado estándar Date. Los cuerpos Problem Details de error no se cachean y conservan sus miembros tal como los define la Sección 5.
Por qué. Un ETag fuerte (RFC 9110 §8.8.1 y §8.8.3) promete que dos respuestas 200 con el mismo valor tienen exactamente los mismos bytes. Un identificador o una hora nuevos en cada respuesta romperían esa promesa: el mismo ETag nombraría cuerpos distintos. Con meta estable, la misma representación serializa siempre a los mismos bytes y el ETag fuerte es honesto.
D. Excepción Documentada: Verificaciones de Salud (/health/live, /health/ready)
Las rutas GET /health/live y GET /health/ready son la única excepción deliberada al sobre { data } o { data, meta } y al formato Problem Details de la Sección 5. Responden con el cuerpo crudo { status, info, error, details } que produce @nestjs/terminus, sin envolverlo, porque el probe de salud de Railway lee ese contrato exacto para decidir si reinicia el contenedor y no entiende el sobre { data } ni Problem Details. HealthController marca ambas rutas con @SkipHttpEnvelope(), así que HttpEnvelopeInterceptor nunca las toca, y las protege con ProtocolExceptionFilter en lugar del ProblemDetailsFilter global: ese filtro reenvía el cuerpo y el código de estado exactos que lanza Terminus (por ejemplo, 503 con { status: 'error', ... } cuando la base de datos no responde) en vez de traducirlos a Problem Details. Ninguna otra ruta del backend queda exenta de esta obligación.
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) que lo declaran, el ETag es fuerte y cubre todo lo que muestra el cuerpo (datay sumetaestable): o es un hash SHA-256 del contenido devuelto (GET /plans,GET /subscription), o sale de una versión que toda escritura que cambia el cuerpo hace avanzar (GET /organization, yGET /organization/structure, que suma un hash de las asignaciones vigentes porque la fecha del sitio las mueve sin escritura). - Encabezado en Respuesta: El servidor envía el encabezado
ETag: "v2-a8f9c1d2e3f4b5a6". - Petición del Cliente: En lecturas subsecuentes, el cliente incluye el encabezado
If-None-Match: "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). 304decidido por el caso de uso: Una lectura cara puede recibir unisCurrent(etag)construido consatisfiesIfNoneMatch(src/foundation/http/conditional-requests/conditional-request.ts, comparación débil de RFC 9110) y decidir el304después de la autorización y antes del SQL pesado (GET /catalog/outlets/{outletId}/sellable-items); el interceptor vuelve a comparar con la misma función, así que la regla es una sola.
http
HTTP/1.1 304 Not Modified
ETag: "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. If-Matchobligatorio (428 Precondition Required). Una escritura que reemplaza una colección completa debe nombrar la versión que reemplaza: se declara con@RequiresIfMatch()(src/foundation/http/conditional-requests/requires-if-match.decorator.ts), que documenta el encabezado como requerido con su412y su428. SinIf-Match, con uno vacío o con*, la API responde428con Problem Detailsprecondition.requiredy un detalle que enseña la corrección (leer el recurso y enviar su ETag), sin escribir nada; un ETag viejo sigue respondiendo412desde el caso de uso. El guard corre después de la sesión y del contexto de tenant y antes de la validación. Hoy lo usan los sietePUTde colecciones completas de Catalog (verCATALOG_USE_CASES.md, "API v7 foundation as built", "API v7 PR 4 as built", que une las imágenes del ítem y de sus variantes en una sola ruta y agrega el conjunto de códigos de barras de una variante,PUT /catalog/items/{itemId}/variants/{variantId}/barcodes, y "API v7 PR 5 as built", que agrega el conjunto de opciones de un grupo de modificadores,PUT /catalog/modifier-groups/{modifierGroupId}, y lleva los grupos que sobrescribe cada variante dentro dePUT /catalog/items/{itemId}/modifier-groups). ElPUT /catalog/item-imagesmasivo no exige el encabezado: cada entrada del cuerpo acepta unifMatchopcional con el ETag de su ítem, y uno viejo rechaza toda la petición con412nombrando cada ítem enmeta.errors.- Primer uso: las escrituras de la estructura de la organización y de su perfil (sitios, marcas, entidades legales, unidades de negocio, puntos de venta, sus asignaciones y su ciclo de vida, y
PATCH /organization) aceptanIf-Matchcon el ETag deGET /organization/structureo deGET /organization, que salen de una versión por organización (organization.structure_versions). El encabezado es opcional: sin él la escritura no es condicional (D24). La comparación ocurre dentro de la transacción de la escritura, sobre la fila de versión bloqueada, de modo que un412no escribe nada. - Rutas de transiciones (
If-Matchopcional). El ciclo de vida de un recurso se cambia con una sola rutaPOST .../transitionscuyo cuerpo es{ "transition": <valor de una lista cerrada> }, nunca con un{ "status" }desnudo: la transición nombra la intención, así que repetirla es seguro yIf-Matches opcional (si se envía y no coincide,412). Se declara con@TransitionRoute()(src/foundation/openapi/api-transition-route.decorator.ts), que documenta la ruta desde la tabla de transiciones del recurso: de qué estado parte y a cuál llega cada transición, cuáles exige el plan, y los403,404,412y422. Una transición que el estado actual no permite responde422con un detalle que nombra el estado y las transiciones permitidas. Hoy la usan los ítems, las variantes, las categorías, los grupos de modificadores, sus opciones y las cartas de Catalog (verCATALOG_USE_CASES.md, "API v7 PR 2 as built" y "API v7 PR 3 as built").
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 técnica de la causa específica del fallo, en inglés, dirigida a quien integra la API. No se traduce ni depende deAccept-Language: el cliente muestra al usuario final su propio texto localizado a partir decode, que es estable y es el contrato (DECISIONS.md D25).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
}
}Exception Messages Never Reach Production Logs
Redaction by name and by known secret cannot catch a one-time code or an email address inside free text, so in production the logger does not depend on callers keeping secrets out of exception messages: it never writes an exception message at all.
- The rule is decided in one place,
src/foundation/logging/redaction/error-disclosure.ts, from theNODE_ENVthe logger already receives throughAppConfigService.serviceIdentity(the process logger reads the validatedNODE_ENV). Onlydevelopmentandtestshow messages;production,staging, any other value and a missing value hide them. - Where it applies: the
errorfield of every record (ErrorLogFields), anyErrorinstance carried inside any other field, and the message Nest passes next to a stack string. Request id and trace id are record envelope fields, so they stay in every line. - What a production line carries about a failure:
name,code(SQLSTATE or the error's own string code),constraint(the PostgreSQL constraint name, when present), theoperation, the stack frames without the message line (everything before the firstatline is dropped, and a stack without frames is dropped entirely), and thecausechain described byname,codeandconstraintonly, to a bounded depth. - What it never carries: the message, the PostgreSQL
detail, the message line of the stack, or a hash of the message (a six-digit code is brute-forceable from a hash). - Outside production the message is kept so developers can debug, still passed through the redaction of known secrets, bearer tokens, basic credentials, URL credentials and
otpauth://URIs. - Call sites never interpolate
error.messageorString(error)into a log message: they hand the failure to the logger withdescribeErrorLogFields(error)(or theErroritself) as theerrorfield, so the rule above applies. Problem Details responses are not logs and keep their own rules (section 5 and section 11).
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"
}B. Límites de Tasa
Dos límites en memoria, sobre el almacenamiento de @nestjs/throttler (src/foundation/http/rate-limiting/):
| Límite | Clave | Valor | Dónde aplica |
|---|---|---|---|
| Por dirección y ruta | IP del cliente (detrás de un proxy de confianza) más el handler | 120 peticiones por minuto (DEFAULT_RATE_LIMIT), más estrecho en las rutas de identidad que lo declaran con @Throttle | Toda ruta, como APP_GUARD |
| Escrituras por miembro | La membresía del contexto de tenant (membershipId); sin contexto de tenant, la IP | MEMBER_WRITE_RATE_LIMIT_PER_MINUTE escrituras por minuto, 60 por defecto, contando juntas todas las escrituras del módulo | Toda ruta de Catalog que no sea GET, HEAD ni OPTIONS (MemberWriteThrottleGuard, después de SessionGuard y TenantContextGuard) |
- Superado un límite, la API responde
429con Problem Detailsrate_limit.exceededy el encabezadoRetry-Afteren segundos (documentado como entero en OpenAPI conApiRateLimitedResponse); el detalle dice que se espere ese tiempo y se repita la escritura. Las lecturas nunca cuentan para el límite por miembro, y otro miembro de la misma organización tiene su propio contador. - Una escritura rechazada después (por ejemplo
403por falta de permiso, que se audita) cuenta igual: así un miembro sin permiso no puede llenaraudit.recordsmás allá del límite. - Limitación conocida. El contador vive en la memoria de cada proceso: con varias instancias de la API detrás del balanceador, cada una cuenta por separado y el límite efectivo se multiplica por el número de instancias; un reinicio lo pone en cero. Es aceptable mientras la API corre en una instancia; un almacenamiento compartido (por ejemplo Redis) es el paso siguiente cuando haya más.
C. Límite de Tamaño del Cuerpo JSON
- Todo cuerpo JSON acepta hasta 100 KB (
DEFAULT_JSON_BODY_LIMIT_BYTES); uno mayor responde413con Problem Detailspayload.too_large. - Solo las rutas masivas cuyo máximo documentado no cabe en 100 KB llevan un techo propio, declarado por su módulo (
CATALOG_BODY_LIMITS) y montado porregisterJsonBodyLimits(src/foundation/http/body-limits/json-body-limits.ts) desdeconfigureHttpApplication, antes que el parser por defecto: un parser JSON por techo que solo acepta su método y su ruta, y después el de 100 KB para todo lo demás. - El parser con techo propio solo lee la petición si trae credenciales
Authorization: Bearer <token>bien formadas (presentedBearerToken). El parser corre antes de cualquier guard, así que sin esa condición un cliente anónimo podría hacer que el servidor lea hasta el techo antes de recibir el401; sin credenciales aplica el límite de 100 KB y un cuerpo mayor recibe413sin leerse. Es una mitigación parcial: un token con forma válida pero falso todavía se lee hasta el techo antes de queSessionGuardlo rechace, acotado por el límite por dirección. - El techo es el escalón de 256 KiB más pequeño que contiene el máximo documentado enviado como JSON compacto, con los textos más largos y en caracteres de 3 bytes; una prueba construye ese cuerpo, comprueba que el esquema lo acepta y que el techo es el escalón justo:
| Ruta | Máximo documentado | Cuerpo máximo | Techo |
|---|---|---|---|
PUT /catalog/menus/{menuId}/structure | 50 secciones y subsecciones, 1.000 entradas, 50 variantes por entrada | 2.143.242 bytes | 2,25 MiB |
PUT /catalog/items/{itemId}/bundle-groups | 20 grupos de 50 opciones | 214.358 bytes | 256 KiB |
PUT /catalog/modifier-groups/{modifierGroupId} | 50 opciones con descripciones de 2.000 caracteres | 347.720 bytes | 512 KiB |
PUT /catalog/items/{itemId}/modifier-groups | 20 grupos de 50 opciones por defecto, en el ítem y en 10 variantes | 459.833 bytes | 512 KiB (el mismo parser) |
PUT /catalog/item-images | 100 ítems de 10 imágenes | 57.111 bytes | 100 KB (por defecto) |
POST /catalog/items/{itemId}/variant-matrix | 3 dimensiones, 50 valores, 100 variantes | 12.446 bytes | 100 KB (por defecto) |
POST .../assortment/listings y .../delistings | 200 ítems | 7.813 bytes | 100 KB (por defecto) |
D. Encabezados de Seguridad
Toda respuesta lleva X-Content-Type-Options: nosniff, para que un navegador nunca interprete un cuerpo JSON o Problem Details como otro tipo, y ninguna lleva X-Powered-By, que solo anunciaría el framework. Los monta applySecurityHeaders (src/foundation/http/security-headers.ts) desde configureHttpApplication, sin la dependencia helmet: el resto de sus encabezados (CSP, frames) protege páginas HTML, que esta API no sirve.
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.