feat(platform): multi-tenancy con credenciales por negocio y sincronización por id

El backend Postgres de `platform/` asumía un solo negocio con un solo token del
CRM. Este cambio lo convierte en una plataforma multi-cuenta y añade la
sincronización selectiva de las cinco entidades del encargo.

## Multi-tenancy

El `locationId` ya era por negocio, pero el token vivía en la variable de entorno
`CRM_TOKEN`, una sola para todo el proceso. Con dos negocios eso usaba el token
del primero contra la subcuenta del segundo: 401 en el mejor caso, escritura en
la subcuenta equivocada en el peor.

- `lib/crypto.ts` — AES-256-GCM para los tokens. Autenticado a propósito: una
  fila manipulada hace que el descifrado FALLE, en vez de devolver basura que
  acabaríamos mandando como credencial al CRM. La clave maestra vive en
  `CRM_MASTER_KEY`, fuera de la base.
- `crm/ctx.ts` — `CrmCtx { businessId, locationId, token }` sustituye al
  `locationId: string` suelto que viajaba por once firmas. Es un objeto y no dos
  parámetros porque dos `string` seguidos se cruzan sin que el compilador diga
  nada, y cruzarlos aquí manda el token de un cliente a la subcuenta de otro. Es
  el único sitio donde el token existe descifrado, y solo en memoria.
- `crm/client.ts` — `CrmOptions.token` pasa a ser OBLIGATORIO, sin valor por
  defecto: olvidarlo es ahora un error de compilación. El estrangulador pasa a
  ser por token y aprende la cuota de las cabeceras `x-ratelimit-*`, que declaran
  100 peticiones por 10 s — el cliente iba 6,5x por debajo con una estimación.
- Migración 003: credencial cifrada, calendario y la red de seguridad de mensajes
  POR NEGOCIO. Como variable global decidía por todas las cuentas a la vez.

Lo único de la credencial que sale del servidor es la huella de 6 caracteres.

## Consola de superadministración

`/api/admin`, solo para el rol `admin`: alta de cuentas con su dueña en una
transacción, vínculo, desvínculo y suspensión. Las credenciales se COMPRUEBAN
contra el CRM antes de guardarse — un token sin validar traslada el fallo al
primer intento de sincronizar, lejos de donde se cometió. El error distingue
«token inválido» de «subcuenta inexistente» de «token de otra subcuenta».

Pantalla en `/admin/cuentas`, verificada en navegador: el campo del token es de
contraseña y viene vacío, porque no hay valor que traer.

## Sincronización por identificador

`POST /api/crm/sync/:entidad/:id` para contacto, conversación, mensaje, cita y
servicio. La dirección la decide la entidad: las tres primeras se TRAEN porque el
CRM es su dueño; las dos últimas se EMPUJAN, porque el calendario del CRM tiene
una sola cita en dos años y su catálogo de servicios está vacío.

- `crm/conversations.ts` — lectura por id de conversaciones y mensajes sueltos.
- `crm/syncConversations.ts` — el espejo persistido. Las tablas existían desde
  002_crm.sql y nadie escribía en ellas: la bandeja consultaba el CRM en vivo.
- `crm/calendars.ts` — escritura de citas al calendario. `isoConDesplazamiento`
  escribe la hora de pared del negocio con su desplazamiento; `toISOString()`
  habría movido la hora que el CRM enseña en su interfaz.
- `crm/services.ts` — publicación de servicios al catálogo.

## Verificado contra la subcuenta real, no deducido

Las cinco entidades se ejercieron contra el CRM del cliente. Las escrituras van
en un ciclo crear → releer → borrar → confirmar borrado, con la limpieza en un
`finally`, y antes se comprobó que el borrado existe: preguntar si se puede
deshacer ANTES de escribir en el CRM de un cliente, no después. La subcuenta
quedó como estaba.

47 hallazgos medidos en `crm/HALLAZGOS.md`, y la referencia de endpoints en
`crm/API.md`, con la lista explícita de dónde la documentación oficial falla.

110 pruebas de plataforma en verde, typecheck limpio, build correcto. El backend
de demo de `server/` no se ha tocado y sigue con sus 43 pruebas.

## Deuda conocida, dicha sin rodeos

- La bandeja de mensajes todavía lee en vivo del CRM, no del espejo.
- La autenticación sigue siendo el id del usuario en texto plano, también para el
  rol admin. Esta consola crea cuentas y guarda credenciales de clientes encima
  de esa base: no debe quedar expuesta a internet hasta endurecerla.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
This commit is contained in:
AgendaPro Dev
2026-08-30 15:07:20 -06:00
co-authored by Claude Opus 5
parent dcbf750c09
commit 6d67b23e55
95 changed files with 16132 additions and 41 deletions
+221
View File
@@ -0,0 +1,221 @@
import { loadEnv } from "../lib/env.ts";
const BASE_URL_DEFAULT = "https://services.leadconnectorhq.com";
/**
* La cabecera `Version` no es opcional y no es una sola: la familia de
* calendarios exige `v3` y el resto `2021-07-28`. Omitirla o equivocarla es un
* 400, y es el error más fácil de cometer al reciclar código entre dominios.
*/
export const VERSION_DEFAULT = "2021-07-28";
export const VERSION_CALENDARS = "v3";
/**
* Intervalo conservador mientras el CRM no diga su cuota real.
*
* MEDIDO (hallazgo 37): las cabeceras `x-ratelimit-*` declaran 100 peticiones
* por 10 s, o sea 1 cada 100 ms — 6,5 veces más de lo que este valor asume. Se
* mantiene como respaldo para la primera petición de un token, antes de haber
* visto ninguna cabecera; a partir de ahí manda `intervaloDe()`.
*/
export const MIN_INTERVAL_MS = 650;
/** Margen sobre la cuota declarada: no se corre al límite exacto. */
const MARGEN = 1.5;
const MAX_RETRIES = 3;
export interface Limites {
max: number;
ventanaMs: number;
restantes: number;
diarioRestante: number | null;
}
const limitesPorToken = new Map<string, Limites>();
/** Registra lo que el CRM dice de su propia cuota. Una respuesta sin cabeceras
* no borra lo ya sabido: no todas las rutas las devuelven. */
export function anotarLimites(token: string, h: Headers): void {
const max = Number(h.get("x-ratelimit-max"));
const ventanaMs = Number(h.get("x-ratelimit-interval-milliseconds"));
if (!max || !ventanaMs) return;
limitesPorToken.set(token, {
max,
ventanaMs,
restantes: Number(h.get("x-ratelimit-remaining") ?? max),
diarioRestante: h.get("x-ratelimit-daily-remaining")
? Number(h.get("x-ratelimit-daily-remaining"))
: null,
});
}
export function limitesDe(token: string): Limites | null {
return limitesPorToken.get(token) ?? null;
}
/**
* Cuánto esperar entre peticiones de ESTE token.
*
* Se toma la cuota que el CRM declara, con un 50 % de margen y no al límite
* exacto: el worker de la bandeja y una sincronización manual pueden coincidir.
* Si la ventana está casi agotada se espacia hasta que se renueve, que sale más
* barato que comerse un 429 y su espera lineal de 5, 10 y 15 s.
*/
export function intervaloDe(token: string): number {
const l = limitesPorToken.get(token);
if (!l) return MIN_INTERVAL_MS;
const base = Math.ceil((l.ventanaMs / l.max) * MARGEN);
if (l.restantes <= 5) {
return Math.max(base, Math.ceil(l.ventanaMs / Math.max(1, l.restantes)));
}
return base;
}
export class CrmError extends Error {
constructor(
readonly status: number,
message: string,
readonly body?: unknown
) {
super(`CRM ${status}: ${message}`);
this.name = "CrmError";
}
}
/**
* Un fallo de transporte no es una respuesta: el servidor no habló, así que no
* se sabe si la escritura entró. Reenviarlo es fabricar la doble creación. Se
* marca aparte para que la bandeja de salida lo deje en `indeterminado` y lo
* resuelva **leyendo**, nunca reintentando.
*/
export class CrmTransportError extends Error {
readonly indeterminate = true;
constructor(message: string) {
super(`CRM sin respuesta: ${message}`);
this.name = "CrmTransportError";
}
}
// Un reloj por token, no uno global: el límite del CRM es por credencial, así
// que un semáforo único serializaría negocios que pueden ir en paralelo. Con
// diez cuentas, la décima esperaría a las nueve anteriores sin ninguna razón.
const ultimaPeticionPorToken = new Map<string, number>();
/** Milisegundos que este token debe esperar antes de su próxima petición. */
export function esperaDeToken(token: string, ahora = Date.now()): number {
const ultima = ultimaPeticionPorToken.get(token) ?? 0;
return Math.max(0, intervaloDe(token) - (ahora - ultima));
}
export function registrarPeticion(token: string, ahora = Date.now()): void {
ultimaPeticionPorToken.set(token, ahora);
}
async function throttle(token: string) {
const espera = esperaDeToken(token);
if (espera > 0) await new Promise((r) => setTimeout(r, espera));
registrarPeticion(token);
}
export interface CrmOptions {
/**
* Token privado de la subcuenta. **Obligatorio y sin valor por defecto.**
*
* Antes caía a `requireEnv("CRM_TOKEN")`, una variable global del proceso: con
* dos negocios, olvidar el token no daba error — usaba el del primero contra
* la subcuenta del segundo. Al hacerlo obligatorio, ese olvido pasa a ser un
* error de compilación, que es el gate real de calidad de este repo.
*
* Sale siempre de `CrmCtx.token` (ver platform/crm/ctx.ts).
*/
token: string;
body?: unknown;
version?: string;
query?: Record<string, string | number | undefined>;
}
export async function crmRequest<T = unknown>(
method: string,
path: string,
opts: CrmOptions
): Promise<T> {
loadEnv();
const base = process.env.CRM_BASE_URL || BASE_URL_DEFAULT;
const token = opts.token;
let url = `${base}${path}`;
if (opts.query) {
const q = new URLSearchParams();
for (const [k, v] of Object.entries(opts.query)) {
if (v !== undefined) q.set(k, String(v));
}
const s = q.toString();
if (s) url += (url.includes("?") ? "&" : "?") + s;
}
const version =
opts.version ?? (path.startsWith("/calendars/") ? VERSION_CALENDARS : VERSION_DEFAULT);
let intento = 0;
for (;;) {
await throttle(token);
let res: Response;
try {
res = await fetch(url, {
method,
headers: {
authorization: `Bearer ${token}`,
version,
accept: "application/json",
...(opts.body !== undefined ? { "content-type": "application/json" } : {}),
},
body: opts.body !== undefined ? JSON.stringify(opts.body) : undefined,
});
} catch (e: any) {
// Timeout / conexión caída: no hubo respuesta. Se reintenta el transporte
// solo en GET, que es idempotente por naturaleza; en escrituras se
// propaga para que arriba se resuelva leyendo.
if (method === "GET" && intento < MAX_RETRIES) {
await new Promise((r) => setTimeout(r, 2000 * 2 ** intento));
intento++;
continue;
}
throw new CrmTransportError(`${e?.name ?? "Error"}: ${e?.message ?? e}`);
}
// El CRM declara su propia cuota en cada respuesta. Leerla es la única
// forma de no ir a ciegas: el intervalo por defecto era una estimación.
anotarLimites(token, res.headers);
if (res.status === 429 || res.status >= 500) {
if (intento < MAX_RETRIES) {
// 429 lineal (5/10/15 s), 5xx exponencial: es la política ya medida en
// el proyecto hermano.
const espera = res.status === 429 ? 5000 * (intento + 1) : 2000 * 2 ** intento;
await new Promise((r) => setTimeout(r, espera));
intento++;
continue;
}
}
const texto = await res.text();
let cuerpo: any = null;
try {
cuerpo = texto ? JSON.parse(texto) : null;
} catch {
cuerpo = texto;
}
if (res.status === 401) {
// Rotar el token es trabajo humano: no se reintenta y se dice claro.
throw new CrmError(401, "Token rechazado — hay que regenerarlo en el CRM", cuerpo);
}
if (!res.ok) {
const msg =
(Array.isArray(cuerpo?.message) ? cuerpo.message.join("; ") : cuerpo?.message) ||
cuerpo?.error ||
res.statusText;
throw new CrmError(res.status, String(msg), cuerpo);
}
return cuerpo as T;
}
}