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 literalcontact-intent. Lo usa la verificación ALTCHA para ligar el challenge a este flujo.CONTACT_GATE_MAX_COUNT— valor15. 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 escribirUser.contactGate. La autoridad del gate vive ahí.verifyAndConsumeAltchaPayload— helper compartido de../../config/altcha.js. Consume el payload y lo ata alpurposey aluserId.ApiError— error usado cuando el request no traealtchaPayloady 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, retornaauthorized: true,remainingyresetAt.authorizeContactIntent(userId, altchaPayload)— si no hay ventana vigente y faltaaltchaPayload, lanzaApiError(400)concode: ALTCHA_REQUIREDyfield: altchaPayload.authorizeContactIntent(userId, altchaPayload)— si no hay ventana vigente y sí hay payload, llama averifyAndConsumeAltchaPayloadconpurpose: CONTACT_GATE_PURPOSEyuserId. Luego abre una ventana nueva convalidatedAt = nowycount = 1.authorizeContactIntent(userId, altchaPayload)— calcularemainingconMath.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,
altchaPayloades obligatorio. Sin ese campo, el servicio lanzaALTCHA_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
altchaPayloadno 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.
Related
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 congenerateAltchaChallenge, llama aauthorizeContactIntenty devuelve la respuesta HTTP. También añadeCache-ControlyPragmapara evitar caché del challenge.services/api/src/modules/contact-gate/contact-gate.routes.js— monta el módulo bajo/api/contact-gate, aplicarequireAuthantes 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 porALTCHA_REQUIRED, lospurposedistintos y la imposibilidad de reusar un payload consumido.services/api/test/contact-gate-rate-limits.test.js— verifica los límites reales deexpress-rate-limiten ambas rutas del módulo.