services/api/src/modules/contact-gate/contact-gate.controller.js

Overview

Este archivo define los dos handlers HTTP del módulo contact-gate. Ambos trabajan sobre una sesión autenticada y se montan bajo /api/contact-gate desde services/api/src/app.js. El router aplica requireAuth antes de los rate limiters, y el negocio real vive en contact-gate.service.js.

El propósito del módulo es proteger la acción de contactar a un anunciante. Según el servicio y la documentación del backend, esto cubre anti-automatización y scraping sobre el contacto, no verificación de identidad, teléfono o reputación.

[!IMPORTANT] Estas rutas siempre requieren sesión. El requireAuth del router se ejecuta antes que los limitadores por usuario, porque sus keyGenerator leen req.auth.userId.

How it works

flowchart TD
    Client[Cliente autenticado]
    RequireAuth[requireAuth]
    ChallengeLimit[challengeRateLimit]
    ChallengeCtl[contactGateAltchaChallenge]
    AltchaGen[generateAltchaChallenge]
    AuthorizeLimit[authorizeRateLimit]
    AuthorizeCtl[contactGateAuthorize]
    AuthorizeSvc[authorizeContactIntent]
    UserGate[User contactGate]
    VerifyAltcha[verifyAndConsumeAltchaPayload]

    Client -->|GET contact gate challenge| RequireAuth
    RequireAuth --> ChallengeLimit
    ChallengeLimit --> ChallengeCtl
    ChallengeCtl --> AltchaGen
    AltchaGen -->|challenge JSON| ChallengeCtl
    ChallengeCtl -->|200 JSON| Client

    Client -->|POST contact gate authorize| RequireAuth
    RequireAuth --> AuthorizeLimit
    AuthorizeLimit --> AuthorizeCtl
    AuthorizeCtl --> AuthorizeSvc
    AuthorizeSvc -->|consume existing window| UserGate
    AuthorizeSvc -->|validate new payload| VerifyAltcha
    VerifyAltcha --> AuthorizeSvc
    AuthorizeSvc -->|200 JSON| AuthorizeCtl
    AuthorizeCtl --> Client

La ruta GET /altcha-challenge genera un challenge ALTCHA ligado al usuario autenticado. La ruta POST /authorize intenta consumir una ventana vigente en User.contactGate, o valida un altchaPayload nuevo cuando la ventana ya no existe. Si el payload no es string, el controlador lo ignora y pasa undefined al servicio.

El controlador no guarda estado propio. Solo adapta la petición HTTP, llama a la capa de servicio o al helper ALTCHA, y delega errores a next(error). El resultado final vuelve como JSON con estado 200.

Key components

El archivo contiene dos handlers públicos. Ambos son funciones async pensadas para Express, con manejo explícito de errores mediante try/catch. También dependen de dos capas externas ya existentes en el módulo.

{
    "title": "contactGateAltchaChallenge",
    "content": "Handler p\u00fablico que genera un challenge ALTCHA ligado al usuario autenticado y responde con JSON. Tambi\u00e9n aplica cabeceras de no cach\u00e9.",
    "type": "function",
    "filePath": "services/api/src/modules/contact-gate/contact-gate.controller.js",
    "badges": [
        "public"
    ]
}
{
    "title": "contactGateAuthorize",
    "content": "Handler p\u00fablico que autoriza una intenci\u00f3n de contacto. Normaliza `altchaPayload`, llama al servicio y devuelve el resultado como JSON.",
    "type": "function",
    "filePath": "services/api/src/modules/contact-gate/contact-gate.controller.js",
    "badges": [
        "public"
    ]
}
{
    "title": "authorizeContactIntent",
    "content": "Funci\u00f3n de servicio que decide si la intenci\u00f3n de contacto ya est\u00e1 autorizada o si necesita un nuevo payload ALTCHA.",
    "type": "function",
    "filePath": "services/api/src/modules/contact-gate/contact-gate.service.js",
    "badges": [
        "public"
    ]
}
{
    "title": "generateAltchaChallenge",
    "content": "Helper compartido que construye el challenge ALTCHA usado por el gate de contacto.",
    "type": "function",
    "filePath": "services/api/src/config/altcha.js",
    "badges": [
        "public"
    ]
}

Data & fields

El controlador solo consume unos pocos datos explícitos. La respuesta final de autorización sí tiene una forma concreta, definida por el servicio. El challenge devuelto por ALTCHA se trata como un objeto opaco en este archivo.

Entrada del request

[
    {
        "name": "req.auth.userId",
        "type": "string",
        "required": true,
        "description": "Identificador del usuario autenticado. Se usa para ligar el challenge y la autorización."
    },
    {
        "name": "req.body.altchaPayload",
        "type": "string",
        "description": "Payload ALTCHA enviado por el cliente. Si no es string, el controlador lo convierte en undefined."
    }
]

Resultado de autorización

[
    {
        "name": "authorized",
        "type": "boolean",
        "required": true,
        "description": "Indica que la intención de contacto quedó autorizada."
    },
    {
        "name": "remaining",
        "type": "number",
        "required": true,
        "description": "Intentos restantes dentro de la ventana vigente."
    },
    {
        "name": "resetAt",
        "type": "string",
        "required": true,
        "description": "Marca temporal asociada a la ventana vigente."
    }
]

Constantes del gate

  • CONTACT_GATE_PURPOSE [badge:read-only] = 'contact-intent' — propósito ALTCHA usado en este flujo.
  • CONTACT_GATE_MAX_COUNT [badge:read-only] = 15 — máximo de intentos autorizados dentro de una ventana.
  • CONTACT_GATE_WINDOW_MS [badge:read-only] = 24 * 60 * 60 * 1000 — ventana de 24 horas desde la última resolución ALTCHA.

Behaviors

El archivo implementa dos comportamientos HTTP claros. Ambos siguen el mismo patrón: leer entrada mínima, delegar, responder con 200, y pasar errores al middleware siguiente.

  • contactGateAltchaChallenge [badge:public] — Llama a generateAltchaChallenge con purpose: CONTACT_GATE_PURPOSE y userId: req.auth.userId. Después fija Cache-Control y Pragma para evitar caché, y devuelve el challenge como JSON.
  • contactGateAuthorize [badge:public] — Lee req.body.altchaPayload solo si es string. Luego llama a authorizeContactIntent(req.auth.userId, altchaPayload) y devuelve el resultado como JSON.

API

El router expone estas rutas bajo /api/contact-gate. El montaje ocurre en services/api/src/app.js, y el módulo aplica autenticación antes de ambos endpoints. El backend y las pruebas confirman además los límites por usuario.

GET /contact-gate/altcha-challenge

{
    "title": "GET contact-gate altcha challenge",
    "description": "Devuelve un challenge ALTCHA ligado al usuario autenticado y al prop\u00f3sito contact-intent.",
    "method": "GET",
    "baseUrl": "/api",
    "endpoint": "/contact-gate/altcha-challenge",
    "headers": [
        {
            "key": "Authorization",
            "value": "Bearer <token>",
            "required": true
        }
    ],
    "queryParams": [],
    "pathParams": [],
    "bodyType": "none",
    "requestBody": "",
    "formData": [],
    "rawBody": "",
    "responses": {
        "200": {
            "description": "Challenge ALTCHA emitido.",
            "body": "{\n  \"parameters\": {\n    \"algorithm\": \"PBKDF2/SHA-256\"\n  }\n}"
        },
        "401": {
            "description": "Sesi\u00f3n requerida.",
            "body": "{\n  \"error\": \"Unauthorized\"\n}"
        },
        "429": {
            "description": "L\u00edmite por usuario superado.",
            "body": "{\n  \"error\": \"Demasiadas solicitudes de verificacion. Intenta mas tarde.\"\n}"
        }
    }
}

POST /contact-gate/authorize

{
    "title": "POST contact-gate authorize",
    "description": "Autoriza una intenci\u00f3n de contacto consumiendo una ventana vigente o validando un payload ALTCHA nuevo.",
    "method": "POST",
    "baseUrl": "/api",
    "endpoint": "/contact-gate/authorize",
    "headers": [
        {
            "key": "Authorization",
            "value": "Bearer <token>",
            "required": true
        },
        {
            "key": "Content-Type",
            "value": "application/json",
            "required": true
        }
    ],
    "queryParams": [],
    "pathParams": [],
    "bodyType": "json",
    "requestBody": "{\n  \"altchaPayload\": \"<base64 altcha payload>\"\n}",
    "formData": [],
    "rawBody": "",
    "responses": {
        "200": {
            "description": "Intenci\u00f3n autorizada.",
            "body": "{\n  \"authorized\": true,\n  \"remaining\": 14,\n  \"resetAt\": \"2026-01-01T00:00:00.000Z\"\n}"
        },
        "400": {
            "description": "Falta el payload ALTCHA. Tambi\u00e9n cubre payloads de otro prop\u00f3sito o ya consumidos.",
            "body": "{\n  \"error\": \"Se requiere completar la verificacion ALTCHA para continuar.\",\n  \"code\": \"ALTCHA_REQUIRED\",\n  \"field\": \"altchaPayload\"\n}"
        },
        "401": {
            "description": "Sesi\u00f3n requerida.",
            "body": "{\n  \"error\": \"Unauthorized\"\n}"
        },
        "429": {
            "description": "L\u00edmite por usuario superado.",
            "body": "{\n  \"error\": \"Demasiados intentos de contacto. Intenta mas tarde.\"\n}"
        }
    }
}

Workflows

El flujo principal tiene dos caminos. Uno emite el challenge y el otro consume o abre la ventana de contacto. Las pruebas de integración confirman ambos recorridos y sus límites.

  • Emisión de challenge — GET /contact-gate/altcha-challenge devuelve un challenge ALTCHA ligado al usuario y responde sin caché.
  • Autorización de contacto — POST /contact-gate/authorize usa una ventana vigente si existe; si no, exige altchaPayload.

Gotchas & edge cases

[!WARNING] contactGateAuthorize solo acepta altchaPayload cuando es una cadena. Cualquier otro tipo se convierte en undefined y el servicio sigue la rama de validación.

[!WARNING] Si no existe una ventana vigente y falta altchaPayload, el servicio lanza ApiError con estado 400. El código de negocio es ALTCHA_REQUIRED, y el campo marcado es altchaPayload.

[!CAUTION] El challenge de contacto no es reutilizable para otros propósitos. Las pruebas confirman que un challenge de otro purpose se rechaza, y que un payload ya consumido también falla con 400.

[!IMPORTANT] Las respuestas del challenge no deben cachearse. El controlador fuerza Cache-Control: no-store, no-cache, must-revalidate, private y Pragma: no-cache.

[!NOTE] El router usa límites por usuario distintos para cada endpoint. GET /altcha-challenge tiene 30 solicitudes por 5 minutos, y POST /authorize tiene 60 por 15 minutos.

Este controlador se relaciona directamente con varios archivos del mismo módulo. El router define el montaje y los límites; el servicio ejecuta la regla de negocio; y altcha.js provee la infraestructura común de challenge y verificación. El app.js monta todo el módulo en la API.

  • services/api/src/modules/contact-gate/contact-gate.routes.js — define GET /altcha-challenge y POST /authorize, aplica requireAuth y rate limiting por usuario.
  • services/api/src/modules/contact-gate/contact-gate.service.js — contiene authorizeContactIntent y las constantes del gate.
  • services/api/src/config/altcha.js — expone generateAltchaChallenge y verifyAndConsumeAltchaPayload, compartidos con otros flujos ALTCHA.
  • services/api/src/app.js — monta el módulo bajo /api/contact-gate.
  • services/api/test/contact-gate.test.js — cubre autenticación, validación ALTCHA, ventana de 15 intentos y expiración a 24 horas.
  • services/api/test/contact-gate-rate-limits.test.js — verifica los límites HTTP reales de 30/5min y 60/15min.