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
+36
View File
@@ -0,0 +1,36 @@
import type { PoolClient } from "pg";
export interface AuditEntry {
businessId: number | null;
actorUserId: number | null;
entity: string;
entityId: number | null;
action: string;
before?: unknown;
after?: unknown;
ip?: string | null;
}
/**
* Escribe una fila de auditoría **con el cliente de la transacción en curso**.
* Recibe el `PoolClient` a propósito y no usa el pool por su cuenta: si el
* cambio se revierte, su rastro tiene que revertirse con él. Una auditoría que
* registra cambios que no ocurrieron es peor que no tener auditoría.
*/
export async function writeAudit(c: PoolClient, e: AuditEntry): Promise<void> {
await c.query(
`INSERT INTO audit_log
(business_id, actor_user_id, entity, entity_id, action, before, after, ip)
VALUES ($1,$2,$3,$4,$5,$6::jsonb,$7::jsonb,$8)`,
[
e.businessId,
e.actorUserId,
e.entity,
e.entityId,
e.action,
e.before === undefined ? null : JSON.stringify(e.before),
e.after === undefined ? null : JSON.stringify(e.after),
e.ip ?? null,
]
);
}
+90
View File
@@ -0,0 +1,90 @@
import type { Request, Response, NextFunction } from "express";
import { pool } from "../db/pool.ts";
export interface PlatformUser {
id: number;
business_id: number | null;
email: string;
name: string;
role: "admin" | "owner" | "employee";
employee_id: number | null;
avatar_color: string;
}
export interface AuthedRequest extends Request {
user?: PlatformUser;
}
/**
* DEUDA CONOCIDA: el token es el id del usuario en texto plano y la contraseña
* se compara sin hashear. Se porta tal cual desde el backend de demo para no
* romper `src/lib/api.ts`, el AuthProvider y los .mjs de prueba en el mismo
* cambio. Endurecerlo es un entregable propio: bcrypt/Argon2id + sesión real +
* los cinco sitios a la vez.
*/
export async function authRequired(
req: AuthedRequest,
res: Response,
next: NextFunction
) {
const header = req.header("authorization") || "";
const token = header.startsWith("Bearer ") ? header.slice(7) : req.header("x-user-id");
if (!token) {
err(res, 401, "No autorizado");
return;
}
const userId = Number(token);
if (!Number.isFinite(userId)) {
err(res, 401, "Token inválido");
return;
}
const { rows } = await pool.query<PlatformUser>(
`SELECT id, business_id, email, name, role, employee_id, avatar_color
FROM users WHERE id = $1`,
[userId]
);
if (!rows[0]) {
err(res, 401, "Usuario no encontrado");
return;
}
req.user = rows[0];
next();
}
export function ownerOnly(req: AuthedRequest, res: Response, next: NextFunction) {
if (req.user?.role !== "owner") {
err(res, 403, "Solo la administradora puede realizar esta acción");
return;
}
next();
}
/**
* Administración de la plataforma: opera todas las cuentas y su `business_id`
* es NULL.
*
* No se confunde con `ownerOnly`, que manda dentro de UN negocio. Son dos
* autoridades distintas: la dueña de un spa no debe poder dar de alta cuentas
* ajenas ni ver las credenciales de nadie.
*/
export function adminOnly(req: AuthedRequest, res: Response, next: NextFunction) {
if (req.user?.role !== "admin") {
err(res, 403, "Solo la administración de la plataforma puede realizar esta acción");
return;
}
next();
}
export function err(res: Response, status: number, message: string) {
return res.status(status).json({ error: message });
}
/** Envuelve un handler async para que un rechazo no cuelgue la petición. */
export function h(fn: (req: AuthedRequest, res: Response) => Promise<unknown>) {
return (req: AuthedRequest, res: Response, next: NextFunction) => {
fn(req, res).catch(next);
};
}
+54
View File
@@ -0,0 +1,54 @@
import type { PoolClient } from "pg";
/**
* Valores con los que nace un negocio.
*
* Existe por la misma razón que su gemelo del backend de demo: un negocio sin
* `working_hours` no tiene ninguna franja agendable en ninguna fecha, y uno sin
* `slug` no tiene página pública. Poner el default en el `INSERT` —y no en una
* migración de relleno— es lo único que cubre a las filas creadas después de que
* la migración ya corrió.
*
* NO se importa desde `server/`: los dos backends conviven sin compartir código,
* y cruzarlos ataría la evolución de uno a la del otro.
*/
export const DEFAULT_WORKING_HOURS = JSON.stringify({
1: { start: "09:00", end: "20:00" },
2: { start: "09:00", end: "20:00" },
3: { start: "09:00", end: "20:00" },
4: { start: "09:00", end: "20:00" },
5: { start: "09:00", end: "20:00" },
6: null,
7: null,
});
/** "Lumière Estética & Spa" → "lumiere-estetica-spa". Puro. */
export function slugify(s: string): string {
return (
(s || "negocio")
.toLowerCase()
.normalize("NFD")
.replace(/[̀-ͯ]/g, "")
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "")
.slice(0, 60) || "negocio"
);
}
/**
* Slug único dentro de la plataforma, con sufijo numérico si ya está tomado.
*
* Recibe el cliente de la transacción, no el pool: comprobar la unicidad en una
* conexión y escribir en otra deja una ventana en la que dos altas simultáneas
* eligen el mismo slug. La restricción `UNIQUE` de la columna es la red final,
* pero conviene no depender de que salte.
*/
export async function uniqueSlugPg(tx: PoolClient, nombre: string): Promise<string> {
const base = slugify(nombre);
let slug = base;
for (let n = 2; ; n++) {
const { rows } = await tx.query(`SELECT 1 FROM businesses WHERE slug = $1`, [slug]);
if (!rows.length) return slug;
slug = `${base}-${n}`;
}
}
+51
View File
@@ -0,0 +1,51 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { cifrar, descifrar, huella } from "./crypto.ts";
const CLAVE = Buffer.alloc(32, 7).toString("base64");
test("cifrar/descifrar: ida y vuelta devuelve el original", () => {
process.env.CRM_MASTER_KEY = CLAVE;
const token = "pit-abc123def456";
assert.equal(descifrar(cifrar(token)), token);
});
test("cifrar: dos cifrados del mismo texto son distintos (nonce aleatorio)", () => {
process.env.CRM_MASTER_KEY = CLAVE;
const a = cifrar("mismo-token");
const b = cifrar("mismo-token");
assert.notEqual(a.cipher.toString("hex"), b.cipher.toString("hex"));
assert.equal(descifrar(a), descifrar(b));
});
test("descifrar: un cipher manipulado lanza, no devuelve basura", () => {
process.env.CRM_MASTER_KEY = CLAVE;
const c = cifrar("token-real");
c.cipher[0] ^= 0xff;
assert.throws(() => descifrar(c), /no se pudo descifrar/i);
});
test("descifrar: con otra clave maestra lanza, no devuelve basura", () => {
process.env.CRM_MASTER_KEY = CLAVE;
const c = cifrar("token-real");
process.env.CRM_MASTER_KEY = Buffer.alloc(32, 9).toString("base64");
assert.throws(() => descifrar(c), /no se pudo descifrar/i);
process.env.CRM_MASTER_KEY = CLAVE;
});
test("huella: son los 6 últimos caracteres, para distinguir tokens sin exponerlos", () => {
assert.equal(huella("pit-abcdef123456"), "123456");
assert.equal(huella("corto"), "corto");
});
test("una clave que no mide 32 bytes se rechaza con un mensaje que lo dice", () => {
process.env.CRM_MASTER_KEY = Buffer.alloc(16, 1).toString("base64");
assert.throws(() => cifrar("x"), /32 bytes/);
process.env.CRM_MASTER_KEY = CLAVE;
});
test("sin CRM_MASTER_KEY se lanza un error que dice qué falta y dónde ponerlo", () => {
delete process.env.CRM_MASTER_KEY;
assert.throws(() => cifrar("x"), /CRM_MASTER_KEY/);
process.env.CRM_MASTER_KEY = CLAVE;
});
+69
View File
@@ -0,0 +1,69 @@
import crypto from "node:crypto";
import { loadEnv } from "./env.ts";
export interface Cifrado {
cipher: Buffer;
nonce: Buffer;
tag: Buffer;
}
/**
* Cifrado de los tokens de subcuenta que se guardan en Postgres.
*
* AES-256-GCM, es decir cifrado **autenticado**, y eso es la decisión que
* importa: si alguien manipula la fila en la base, `descifrar` lanza en vez de
* devolver basura. Con un cifrado sin autenticar, una fila corrupta se
* convertiría en una petición al CRM con una credencial mal formada, y el fallo
* aparecería lejos de su causa.
*
* La clave maestra vive en el entorno, nunca en la base: quien consiga un
* volcado de Postgres no consigue los tokens de los clientes.
*/
function clave(): Buffer {
loadEnv();
const b64 = process.env.CRM_MASTER_KEY;
if (!b64) {
throw new Error(
'Falta CRM_MASTER_KEY. Genera una con: node -e "console.log(require(\'crypto\').randomBytes(32).toString(\'base64\'))" y ponla en platform/.env'
);
}
const k = Buffer.from(b64, "base64");
if (k.length !== 32) {
throw new Error(
`CRM_MASTER_KEY debe ser de 32 bytes en base64; llegaron ${k.length}. Genera una nueva con randomBytes(32).`
);
}
return k;
}
export function cifrar(claro: string): Cifrado {
const nonce = crypto.randomBytes(12);
const c = crypto.createCipheriv("aes-256-gcm", clave(), nonce);
const cipher = Buffer.concat([c.update(claro, "utf8"), c.final()]);
return { cipher, nonce, tag: c.getAuthTag() };
}
export function descifrar(c: Cifrado): string {
// La clave se pide FUERA del try: si falta o mide mal, ese error debe salir
// tal cual, no disfrazado de «fila corrupta». Son dos causas distintas y
// llevan a dos arreglos distintos.
const k = clave();
try {
const d = crypto.createDecipheriv("aes-256-gcm", k, c.nonce);
d.setAuthTag(c.tag);
return Buffer.concat([d.update(c.cipher), d.final()]).toString("utf8");
} catch {
throw new Error(
"El token guardado no se pudo descifrar: la clave maestra cambió o la fila está corrupta. Hay que volver a vincular la subcuenta."
);
}
}
/**
* Los 6 últimos caracteres del token. Sirve para que la interfaz pueda decir
* «termina en …f4a2c1» y para detectar una rotación, sin exponer nunca la
* credencial completa ni en la API, ni en los registros, ni en la auditoría.
*/
export function huella(token: string): string {
return token.length <= 6 ? token : token.slice(-6);
}
+50
View File
@@ -0,0 +1,50 @@
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const ENV_PATH = path.resolve(__dirname, "..", ".env");
let cargado = false;
/**
* Lee `platform/.env` y lo vuelca en `process.env` sin pisar lo que ya viniera
* del entorno — un valor exportado en la terminal gana al archivo, que es lo
* que se espera al apuntar a otra subcuenta sin editar nada.
*
* Sin dependencia externa a propósito: son quince líneas y el archivo lleva
* el token del CRM, así que conviene que se vea exactamente qué lo lee.
*/
export function loadEnv(): void {
if (cargado) return;
cargado = true;
if (!fs.existsSync(ENV_PATH)) return;
for (const raw of fs.readFileSync(ENV_PATH, "utf8").split(/\r?\n/)) {
const line = raw.trim();
if (!line || line.startsWith("#")) continue;
const eq = line.indexOf("=");
if (eq < 1) continue;
const key = line.slice(0, eq).trim();
let value = line.slice(eq + 1).trim();
if (
(value.startsWith('"') && value.endsWith('"')) ||
(value.startsWith("'") && value.endsWith("'"))
) {
value = value.slice(1, -1);
}
if (process.env[key] === undefined) process.env[key] = value;
}
}
/** Lee una variable obligatoria, con un mensaje que dice qué falta y dónde ponerlo. */
export function requireEnv(key: string): string {
loadEnv();
const v = process.env[key];
if (!v) {
throw new Error(
`Falta ${key}. Defínelo en platform/.env (ver platform/.env.example) o expórtalo en el entorno.`
);
}
return v;
}
+46
View File
@@ -0,0 +1,46 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { normalizePhone } from "./phone.ts";
test("normaliza las formas mexicanas de diez dígitos", () => {
assert.equal(normalizePhone("5588887777"), "+525588887777");
assert.equal(normalizePhone("55 8888 7777"), "+525588887777");
assert.equal(normalizePhone("(55) 8888-7777"), "+525588887777");
assert.equal(normalizePhone("55.8888.7777"), "+525588887777");
});
test("acepta el prefijo de larga distancia 01", () => {
assert.equal(normalizePhone("01 55 8888 7777"), "+525588887777");
});
test("acepta el 52 con y sin más", () => {
assert.equal(normalizePhone("+52 55 8888 7777"), "+525588887777");
assert.equal(normalizePhone("525588887777"), "+525588887777");
assert.equal(normalizePhone("0052 55 8888 7777"), "+525588887777");
});
test("colapsa el 521 heredado de WhatsApp al formato actual", () => {
// El 1 después del 52 era el marcador de móvil; desde 2019 ya no se disca,
// pero sigue apareciendo en los identificadores de mensajería.
assert.equal(normalizePhone("5215588887777"), "+525588887777");
assert.equal(normalizePhone("+52 1 55 8888 7777"), "+525588887777");
});
test("respeta un internacional que no es México", () => {
assert.equal(normalizePhone("+1 305 555 0134"), "+13055550134");
assert.equal(normalizePhone("+34 600 123 456"), "+34600123456");
});
test("devuelve null cuando no se puede normalizar", () => {
assert.equal(normalizePhone(null), null);
assert.equal(normalizePhone(""), null);
assert.equal(normalizePhone(" "), null);
assert.equal(normalizePhone("no tengo"), null);
assert.equal(normalizePhone("123"), null, "demasiado corto");
assert.equal(normalizePhone("12345678901234567"), null, "demasiado largo");
});
test("es idempotente sobre su propia salida", () => {
const once = normalizePhone("55 8888 7777")!;
assert.equal(normalizePhone(once), once);
});
+53
View File
@@ -0,0 +1,53 @@
/**
* Normaliza un teléfono a E.164 (`+` seguido de 8 a 15 dígitos).
*
* Es la clave de identidad de la clienta: sin ella, el mismo número tecleado de
* dos formas produce dos fichas, y la auditoría del spa midió que el teléfono es
* el único campo con cobertura suficiente para reconciliar canales.
*
* Devuelve `null` cuando no se puede normalizar con certeza. `null` no es un
* error: significa "clienta no contactable", que es un estado legítimo y medido
* (40.8 % del histórico). Nunca se inventa un país para rellenarlo.
*/
export function normalizePhone(
raw: string | null | undefined,
defaultCountry = "52"
): string | null {
if (raw == null) return null;
const trimmed = String(raw).trim();
if (!trimmed) return null;
// Una letra en el campo significa texto libre ("no tengo", "el de su mamá"),
// no un teléfono mal escrito. No se intenta rescatar.
if (/[a-zA-Z]/.test(trimmed)) return null;
const explicitIntl = trimmed.startsWith("+") || /^00\d/.test(trimmed);
let digits = trimmed.replace(/\D/g, "");
if (trimmed.startsWith("00")) digits = digits.slice(2);
if (!digits) return null;
if (!explicitIntl) {
// "01" es el prefijo mexicano de larga distancia y se quita como unidad, no
// como "ceros a la izquierda": si solo se quitara el 0, el 1 restante se
// confundiría con el código de país de Estados Unidos.
if (digits.length === 12 && digits.startsWith("01")) {
digits = digits.slice(2);
} else {
digits = digits.replace(/^0+/, "");
}
}
// "52 1 XXXXXXXXXX": el 1 de móvil que WhatsApp sigue arrastrando.
if (digits.length === 13 && digits.startsWith(`${defaultCountry}1`)) {
digits = defaultCountry + digits.slice(3);
}
// Diez dígitos sueltos = número nacional.
if (!explicitIntl && digits.length === 10) {
digits = defaultCountry + digits;
}
if (digits.length < 8 || digits.length > 15) return null;
return `+${digits}`;
}