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

Overview

Este archivo centraliza la lógica backend del Gate ALTCHA de contacto. Protege la acción de contactar a un anunciante, como revelar o usar teléfono, WhatsApp o Telegram. No verifica identidad, teléfono ni reputación. Esa intención aparece explícita en el comentario del módulo y en la documentación del flujo asociado.

El servicio exporta el propósito ALTCHA contact-intent, la ventana de autorización y la función authorizeContactIntent. Esa función usa User.contactGate como autoridad única y se consume desde el controlador del módulo. El controlador recibe req.auth.userId desde rutas autenticadas.

[!IMPORTANT] La autorización vive en el backend y en Mongo. El cliente no decide si una intención de contacto está autorizada.

How it works

sequenceDiagram
    participant Caller
    participant Service as authorizeContactIntent
    participant UserModel as User
    participant Altcha as verifyAndConsumeAltchaPayload

    Caller->>Service: userId and altchaPayload
    Service->>Service: compute now and windowStart
    Service->>UserModel: findOneAndUpdate with validatedAt and count filters
    alt active window found
        UserModel-->>Service: contactGate document
        Service-->>Caller: authorized true, remaining, resetAt
    else no active window
        alt no altchaPayload
            Service-->>Caller: ApiError 400 ALTCHA_REQUIRED
        else altchaPayload present
            Service->>Altcha: verify and consume with purpose contact-intent
            Altcha-->>Service: payload accepted
            Service->>UserModel: findByIdAndUpdate set validatedAt and count
            UserModel-->>Service: contactGate reset
            Service-->>Caller: authorized true, remaining 14, resetAt
        end
    end

La función intenta primero consumir una ventana ya vigente. Lo hace con findOneAndUpdate, y el filtro de vigencia viaja en la misma operación. Si encuentra coincidencia, incrementa contactGate.count de forma atómica y devuelve la autorización. Si no encuentra ventana, exige altchaPayload, valida el challenge con purpose: contact-intent y abre una ventana nueva con count = 1.

Key components

Este módulo tiene una sola función pública principal. El resto son constantes de contrato y dependencias directas del flujo de autorización.

{
    "title": "authorizeContactIntent",
    "content": "Autoriza una intenci\u00f3n de contacto. Reutiliza una ventana vigente o consume un ALTCHA nuevo, y devuelve el estado final de autorizaci\u00f3n.",
    "type": "function",
    "filePath": "services/api/src/modules/contact-gate/contact-gate.service.js",
    "badges": [
        "public"
    ]
}

Constants

  • CONTACT_GATE_PURPOSE — valor literal contact-intent. Lo usa la verificación ALTCHA para ligar el challenge a este flujo.
  • CONTACT_GATE_MAX_COUNT — valor 15. Define el máximo de intenciones de contacto dentro de una ventana.
  • CONTACT_GATE_WINDOW_MS — 24 * 60 * 60 * 1000. Define una ventana de 24 horas desde la última resolución ALTCHA.

Dependencies

  • User — modelo usado para leer y escribir User.contactGate. La autoridad del gate vive ahí.
  • verifyAndConsumeAltchaPayload — helper compartido de ../../config/altcha.js. Consume el payload y lo ata al purpose y al userId.
  • ApiError — error usado cuando el request no trae altchaPayload y no existe una ventana vigente.

Data & fields

El servicio trabaja con tres grupos de datos claros. Uno entra como identificador de usuario. Otro entra como payload ALTCHA opcional. El tercero vive en User.contactGate y persiste el estado del gate.

[
    {
        "name": "userId",
        "type": "string",
        "required": true,
        "description": "Identificador del usuario autenticado. Se usa como _id en las consultas sobre User."
    },
    {
        "name": "altchaPayload",
        "type": "string",
        "description": "Payload ALTCHA opcional. Solo es necesario cuando no existe una ventana vigente."
    },
    {
        "name": "User.contactGate",
        "type": "object",
        "required": true,
        "description": "Estado persistido del gate de contacto.",
        "children": [
            {
                "name": "validatedAt",
                "type": "Date",
                "required": true,
                "description": "Fecha de la última resolución ALTCHA que abrió la ventana actual."
            },
            {
                "name": "count",
                "type": "number",
                "required": true,
                "description": "Intentos de contacto consumidos dentro de la ventana actual."
            }
        ]
    },
    {
        "name": "authorized",
        "type": "boolean",
        "required": true,
        "description": "Marca que la intención de contacto quedó autorizada."
    },
    {
        "name": "remaining",
        "type": "number",
        "required": true,
        "description": "Intentos disponibles dentro de la ventana actual después de la operación."
    },
    {
        "name": "resetAt",
        "type": "Date",
        "required": true,
        "description": "Marca temporal usada para reiniciar la ventana actual."
    }
]

Behaviors

La función exportada concentra toda la lógica de decisión. Su comportamiento depende de si existe una ventana válida en User.contactGate.

  • authorizeContactIntent(userId, altchaPayload) — intenta consumir una ventana vigente. Si lo logra, retorna authorized: true, remaining y resetAt.
  • authorizeContactIntent(userId, altchaPayload) — si no hay ventana vigente y falta altchaPayload, lanza ApiError(400) con code: ALTCHA_REQUIRED y field: altchaPayload.
  • authorizeContactIntent(userId, altchaPayload) — si no hay ventana vigente y sí hay payload, llama a verifyAndConsumeAltchaPayload con purpose: CONTACT_GATE_PURPOSE y userId. Luego abre una ventana nueva con validatedAt = now y count = 1.
  • authorizeContactIntent(userId, altchaPayload) — calcula remaining con Math.max(0, CONTACT_GATE_MAX_COUNT - consumed.contactGate.count). Eso evita valores negativos.
  • authorizeContactIntent(userId, altchaPayload) — usa dos escrituras atómicas como máximo. No hace una lectura separada seguida de una escritura no protegida.

API

Este archivo no expone HTTP directamente. Aun así, su función forma parte de dos endpoints del módulo contact-gate. El controlador y las rutas viven en archivos vecinos del mismo módulo.

GET /contact-gate/altcha-challenge

Este endpoint emite el challenge ALTCHA ligado al usuario autenticado y al propósito contact-intent. El challenge real lo genera generateAltchaChallenge en el controlador.

{
    "title": "GET /contact-gate/altcha-challenge",
    "description": "Emite un challenge ALTCHA ligado al usuario autenticado y al purpose 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 emitido",
            "body": "<ALTCHA challenge object returned by generateAltchaChallenge>"
        },
        "401": {
            "description": "No autenticado",
            "body": "{\n  \"error\": \"Unauthorized\"\n}"
        },
        "429": {
            "description": "Rate limit aplicado por usuario",
            "body": "{\n  \"error\": \"Demasiadas solicitudes de verificacion. Intenta mas tarde.\"\n}"
        }
    }
}

POST /contact-gate/authorize

Este endpoint consume una ventana vigente o valida un payload ALTCHA para abrir una nueva. El controlador lee altchaPayload desde req.body y llama a authorizeContactIntent.

{
    "title": "POST /contact-gate/authorize",
    "description": "Consume una ventana vigente o valida un payload ALTCHA para abrir una nueva ventana de contacto.",
    "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\": \"<payload opcional>\"\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",
            "body": "{\n  \"code\": \"ALTCHA_REQUIRED\",\n  \"field\": \"altchaPayload\",\n  \"message\": \"Se requiere completar la verificacion ALTCHA para continuar.\"\n}"
        },
        "401": {
            "description": "No autenticado",
            "body": "{\n  \"error\": \"Unauthorized\"\n}"
        },
        "429": {
            "description": "Rate limit aplicado por usuario",
            "body": "{\n  \"error\": \"Demasiados intentos de contacto. Intenta mas tarde.\"\n}"
        }
    }
}

[!NOTE] Los payloads de otro propósito, o ya consumidos, también terminan en rechazo. Esa parte la aplica verifyAndConsumeAltchaPayload, y la suite de pruebas lo cubre.

Gotchas & edge cases

Este servicio tiene varios comportamientos cerrados y predecibles. Todos dependen del estado persistido en User.contactGate.

[!WARNING] Si no existe una ventana vigente, altchaPayload es obligatorio. Sin ese campo, el servicio lanza ALTCHA_REQUIRED.

[!IMPORTANT] La vigencia se decide dentro del propio findOneAndUpdate. No hay una lectura previa independiente que pueda desalinearse con una escritura concurrente.

[!CAUTION] Un altchaPayload no se puede reutilizar para abrir otra ventana. La suite de pruebas confirma ese rechazo.

[!NOTE] La ventana expira por dos causas. Se agotan 15 intentos, o pasan 24 horas desde la última resolución ALTCHA. Lo que ocurra primero fuerza un nuevo challenge.

Este servicio se integra con el controlador, las rutas y las pruebas del mismo módulo. Cada pieza cubre una parte distinta del flujo.

  • services/api/src/modules/contact-gate/contact-gate.controller.js — genera el challenge con generateAltchaChallenge, llama a authorizeContactIntent y devuelve la respuesta HTTP. También añade Cache-Control y Pragma para evitar caché del challenge.
  • services/api/src/modules/contact-gate/contact-gate.routes.js — monta el módulo bajo /api/contact-gate, aplica requireAuth antes de los rate limiters y define límites por usuario para challenge y authorize.
  • services/api/test/contact-gate.test.js — valida la ventana de 15 intentos y 24 horas, el rechazo por ALTCHA_REQUIRED, los purpose distintos y la imposibilidad de reusar un payload consumido.
  • services/api/test/contact-gate-rate-limits.test.js — verifica los límites reales de express-rate-limit en ambas rutas del módulo.