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"
  },
  "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 truncated o seats).

meta es cerrado. La regla, para toda ruta con sobre:

  1. meta siempre está presente, aunque esté vacío.
  2. Cada ruta documenta exactamente sus claves de meta, todas requeridas, con additionalProperties: false. Una ruta que no declara ninguna documenta { "type": "object", "additionalProperties": false, "maxProperties": 0 }. El esquema compartido Envelope no abre meta: deja que cada ruta lo cierre (ApiDataResponse y ApiCollectionResponse en src/foundation/openapi/api-envelope-response.decorator.ts).
  3. Ningún encabezado se copia a meta (ni el identificador de petición, ni la hora, ni el ETag).
  4. Una colección paginada declara pageInfo y 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 declara truncated (por eso GET /catalog/categories y GET /catalog/modifier-groups ya no lo llevan: su tope es el mismo límite de creación).
  5. 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: ​

  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) que lo declaran, el ETag es fuerte y cubre todo lo que muestra el cuerpo (data y su meta estable): 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, y GET /organization/structure, que suma un hash de las asignaciones vigentes porque la fecha del sitio las mueve sin escritura).
  2. Encabezado en Respuesta: El servidor envía el encabezado ETag: "v2-a8f9c1d2e3f4b5a6".
  3. Petición del Cliente: En lecturas subsecuentes, el cliente incluye el encabezado If-None-Match: "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).
  5. 304 decidido por el caso de uso: Una lectura cara puede recibir un isCurrent(etag) construido con satisfiesIfNoneMatch (src/foundation/http/conditional-requests/conditional-request.ts, comparación débil de RFC 9110) y decidir el 304 despué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_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.
  3. If-Match obligatorio (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 su 412 y su 428. Sin If-Match, con uno vacío o con *, la API responde 428 con Problem Details precondition.required y un detalle que enseña la corrección (leer el recurso y enviar su ETag), sin escribir nada; un ETag viejo sigue respondiendo 412 desde 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 siete PUT de colecciones completas de Catalog (ver CATALOG_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 de PUT /catalog/items/{itemId}/modifier-groups). El PUT /catalog/item-images masivo no exige el encabezado: cada entrada del cuerpo acepta un ifMatch opcional con el ETag de su ítem, y uno viejo rechaza toda la petición con 412 nombrando cada ítem en meta.errors.
  4. 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) aceptan If-Match con el ETag de GET /organization/structure o de GET /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 un 412 no escribe nada.
  5. Rutas de transiciones (If-Match opcional). El ciclo de vida de un recurso se cambia con una sola ruta POST .../transitions cuyo 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 y If-Match es 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 los 403, 404, 412 y 422. Una transición que el estado actual no permite responde 422 con 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 (ver CATALOG_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? ​

  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 técnica de la causa específica del fallo, en inglés, dirigida a quien integra la API. No se traduce ni depende de Accept-Language: el cliente muestra al usuario final su propio texto localizado a partir de code, 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:

  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
  }
}

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 the NODE_ENV the logger already receives through AppConfigService.serviceIdentity (the process logger reads the validated NODE_ENV). Only development and test show messages; production, staging, any other value and a missing value hide them.
  • Where it applies: the error field of every record (ErrorLogFields), any Error instance 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), the operation, the stack frames without the message line (everything before the first at line is dropped, and a stack without frames is dropped entirely), and the cause chain described by name, code and constraint only, 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.message or String(error) into a log message: they hand the failure to the logger with describeErrorLogFields(error) (or the Error itself) as the error field, 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 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"
}

B. Límites de Tasa ​

Dos límites en memoria, sobre el almacenamiento de @nestjs/throttler (src/foundation/http/rate-limiting/):

LímiteClaveValorDónde aplica
Por dirección y rutaIP del cliente (detrás de un proxy de confianza) más el handler120 peticiones por minuto (DEFAULT_RATE_LIMIT), más estrecho en las rutas de identidad que lo declaran con @ThrottleToda ruta, como APP_GUARD
Escrituras por miembroLa membresía del contexto de tenant (membershipId); sin contexto de tenant, la IPMEMBER_WRITE_RATE_LIMIT_PER_MINUTE escrituras por minuto, 60 por defecto, contando juntas todas las escrituras del móduloToda ruta de Catalog que no sea GET, HEAD ni OPTIONS (MemberWriteThrottleGuard, después de SessionGuard y TenantContextGuard)
  • Superado un límite, la API responde 429 con Problem Details rate_limit.exceeded y el encabezado Retry-After en segundos (documentado como entero en OpenAPI con ApiRateLimitedResponse); 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 403 por falta de permiso, que se audita) cuenta igual: así un miembro sin permiso no puede llenar audit.records má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 responde 413 con Problem Details payload.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 por registerJsonBodyLimits (src/foundation/http/body-limits/json-body-limits.ts) desde configureHttpApplication, 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 el 401; sin credenciales aplica el límite de 100 KB y un cuerpo mayor recibe 413 sin leerse. Es una mitigación parcial: un token con forma válida pero falso todavía se lee hasta el techo antes de que SessionGuard lo 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:
RutaMáximo documentadoCuerpo máximoTecho
PUT /catalog/menus/{menuId}/structure50 secciones y subsecciones, 1.000 entradas, 50 variantes por entrada2.143.242 bytes2,25 MiB
PUT /catalog/items/{itemId}/bundle-groups20 grupos de 50 opciones214.358 bytes256 KiB
PUT /catalog/modifier-groups/{modifierGroupId}50 opciones con descripciones de 2.000 caracteres347.720 bytes512 KiB
PUT /catalog/items/{itemId}/modifier-groups20 grupos de 50 opciones por defecto, en el ítem y en 10 variantes459.833 bytes512 KiB (el mismo parser)
PUT /catalog/item-images100 ítems de 10 imágenes57.111 bytes100 KB (por defecto)
POST /catalog/items/{itemId}/variant-matrix3 dimensiones, 50 valores, 100 variantes12.446 bytes100 KB (por defecto)
POST .../assortment/listings y .../delistings200 ítems7.813 bytes100 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:

  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.