Files
AgendaPro/docs/superpowers/plans/2026-08-29-multitenant-credenciales-crm.md
AgendaPro DevandClaude Opus 5 6d67b23e55 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]>
2026-08-30 15:07:20 -06:00

50 KiB

Multi-tenancy real: credenciales de Bucéfalo CRM por negocio — Plan de implementación

Para trabajadores agénticos: SUB-SKILL OBLIGATORIA: usa superpowers:subagent-driven-development (recomendado) o superpowers:executing-plans para ejecutar este plan tarea a tarea. Los pasos usan casillas (- [ ]) para llevar el control. Táchalas al completarlas — el plan hermano de 2026-08-29 quedó con 64 casillas sin tachar pese a estar ejecutado, y quien lo retomó sin leer el addendum concluyó que no se había hecho nada.

Objetivo: que platform/ deje de asumir un solo negocio con un solo token, de modo que el superadministrador pueda dar de alta cuentas y vincular cada una a su subcuenta de Bucéfalo CRM con su propio locationId y su token privado, guardado cifrado.

Arquitectura: hoy el locationId ya es por negocio (crm_connections.business_id UNIQUE) pero el token es una variable de entorno global del proceso (platform/crm/client.ts:66, requireEnv("CRM_TOKEN")). Con dos negocios eso usaría el token del primero contra la subcuenta del segundo: 401 en el mejor caso, escritura en la subcuenta equivocada en el peor. Este plan introduce un CrmCtx { businessId, locationId, token } que sustituye al locationId: string suelto que hoy viaja por todas las firmas, hace el token obligatorio en el tipo para que el compilador —y no la revisión— impida olvidarlo, y lo guarda cifrado con AES-256-GCM en crm_connections. Encima de eso se añade la superficie de superadministrador.

Stack: TypeScript + Express 4 + pg sobre PostgreSQL 16; node:crypto para el cifrado (sin dependencia nueva); node:test para las pruebas; React 18 + React Query en el frontend.

Especificación de origen: platform/crm/HALLAZGOS.md (32 hallazgos medidos contra la subcuenta real), docs/yola-franco-spa-plataforma-propuesta-tecnica.md §6.1 y docs/superpowers/plans/2026-08-29-yola-nucleo-postgres.md.

Plan hermano: la sincronización por id de contactos, conversaciones, mensajes, citas y servicios va en un plan aparte y depende de que este esté hecho, porque consume CrmCtx.


Restricciones globales

  • El token nunca se guarda en claro ni se devuelve nunca por la API. Ni en GET /crm/status, ni en las rutas de administración, ni en logs, ni en audit_log. Lo único que puede salir es la huella (token_fingerprint), que son los 6 últimos caracteres.
  • Node.js >= 22.5. Ya lo valida scripts/preflight.mjs.
  • Todo el texto visible y los mensajes de error de la API van en español. Es la convención del repo (CLAUDE.md).
  • Al CRM se le llama «Bucéfalo CRM» en código, comentarios, documentación e interfaz.
  • Toda consulta filtra por business_id. Es la invariante central; ninguna ruta acepta un business_id que venga del cuerpo de la petición, salvo las de superadministrador, que lo toman de la ruta y exigen rol admin.
  • Las pruebas corren contra yola_test con --test-concurrency=1: cada archivo hace DROP SCHEMA public y en paralelo se pisan.
  • Nunca se acepta un 200 como prueba. Toda escritura contra el CRM se verifica releyendo. Es la regla que produjo los 32 hallazgos y no se relaja aquí.
  • No hacer commits salvo que se pidan explícitamente (política del repo). La verificación por tarea es npm run typecheck + npm run test:platform.

Estructura de archivos

Archivo Responsabilidad
platform/db/migrations/003_multitenant.sql Crear. Columnas de credencial cifrada y de ajustes por negocio en crm_connections.
platform/lib/crypto.ts Crear. Cifrar/descifrar con AES-256-GCM y derivar la huella. Puro, sin base de datos.
platform/lib/crypto.test.ts Crear. Ida y vuelta, detección de manipulación, clave ausente.
platform/crm/ctx.ts Crear. El tipo CrmCtx y ctxDe(businessId), que carga la conexión y descifra el token. Único punto donde el token existe en claro.
platform/crm/client.ts Modificar. token obligatorio; estrangulador por token en vez de global.
platform/crm/connection.ts Modificar. Guardar credenciales cifradas; autoconfigurar recibe el token.
platform/crm/contacts.ts Modificar. Las 4 funciones exportadas pasan a recibir CrmCtx.
platform/crm/messages.ts Modificar. Las 3 funciones exportadas pasan a recibir CrmCtx.
platform/crm/opportunities.ts Modificar. Las funciones que llaman al CRM pasan a recibir CrmCtx.
platform/crm/syncContacts.ts, syncAppointments.ts, outbox.ts Modificar. Obtienen el CrmCtx del negocio y lo propagan.
platform/lib/auth.ts Modificar. Añadir adminOnly.
platform/routes/admin.ts Crear. Alta y gestión de cuentas y de su vínculo con el CRM.
platform/routes/crm.ts Modificar. POST /connect acepta credenciales; GET /status nunca devuelve el token.
platform/routes/messages.ts Modificar. La red de seguridad de envíos pasa a ser por negocio.
platform/index.ts Modificar. Montar /api/admin.
platform/test/admin.test.ts Crear. Alta de cuentas, aislamiento por rol, y que el token no se filtre.
platform/test/crmCtx.test.ts Crear. Que dos negocios usen credenciales distintas.
shared/types.ts Modificar. Tipos de cuenta y de conexión para el frontend.
src/lib/api.ts Modificar. Métodos admin.*.
src/pages/admin/PlatformAccountsPage.tsx Crear. Pantalla del superadministrador.

Tarea 1: Cifrado de credenciales

Archivos:

  • Crear: platform/lib/crypto.ts
  • Crear: platform/lib/crypto.test.ts
  • Modificar: platform/.env.example

Interfaces:

  • Consume: nada.
  • Produce: cifrar(claro: string): Cifrado, descifrar(c: Cifrado): string, huella(token: string): string, interface Cifrado { cipher: Buffer; nonce: Buffer; tag: Buffer }.

Por qué AES-256-GCM y no cifrado simétrico simple: GCM es autenticado. Si alguien manipula la fila en la base, el descifrado falla en vez de devolver basura que luego se manda como token al CRM. Es la diferencia entre un error claro y una petición con una credencial corrupta.

  • Paso 1: Escribir la prueba que falla
// platform/lib/crypto.test.ts
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("huella: son los 6 últimos caracteres, para poder distinguir tokens sin exponerlos", () => {
  assert.equal(huella("pit-abcdef123456"), "123456");
  assert.equal(huella("corto"), "corto");
});

test("sin CRM_MASTER_KEY se lanza un error que dice qué falta", () => {
  delete process.env.CRM_MASTER_KEY;
  assert.throws(() => cifrar("x"), /CRM_MASTER_KEY/);
});
  • Paso 2: Correr la prueba y verificar que falla

Ejecuta: node --import tsx --test platform/lib/crypto.test.ts Esperado: FALLA con «Cannot find module './crypto.ts'».

  • Paso 3: Implementar
// platform/lib/crypto.ts
import crypto from "node:crypto";
import { loadEnv } from "./env.ts";

export interface Cifrado {
  cipher: Buffer;
  nonce: Buffer;
  tag: Buffer;
}

/**
 * AES-256-GCM: cifrado AUTENTICADO a propósito. Si alguien manipula la fila en
 * la base, `descifrar` lanza en vez de devolver basura que acabaríamos mandando
 * como token al CRM. Un cifrado sin autenticar convertiría una fila corrupta en
 * una petición silenciosamente mal formada.
 */
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}`);
  }
  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 {
  try {
    const d = crypto.createDecipheriv("aes-256-gcm", clave(), c.nonce);
    d.setAuthTag(c.tag);
    return Buffer.concat([d.update(c.cipher), d.final()]).toString("utf8");
  } catch (e: any) {
    if (String(e?.message ?? "").includes("CRM_MASTER_KEY")) throw e;
    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. Sirve para distinguir y auditar sin exponer nada. */
export function huella(token: string): string {
  return token.length <= 6 ? token : token.slice(-6);
}
  • Paso 4: Correr la prueba y verificar que pasa

Ejecuta: node --import tsx --test platform/lib/crypto.test.ts Esperado: 5 pruebas, 5 pass.

  • Paso 5: Documentar la variable

Añade a platform/.env.example, debajo del bloque del CRM:

# Clave maestra con la que se cifran los tokens de cada subcuenta en Postgres.
# 32 bytes en base64. Genérala una vez con:
#   node -e "console.log(require('crypto').randomBytes(32).toString('base64'))"
# Si la pierdes, los tokens guardados dejan de descifrarse y hay que volver a
# vincular cada subcuenta a mano. Guárdala donde guardas los secretos, no aquí.
CRM_MASTER_KEY=

Y en el mismo archivo, marca CRM_TOKEN y CRM_LOCATION_ID como heredados:

# HEREDADAS: solo las usa el script de vinculación inicial para el primer
# negocio. A partir de la Tarea 6 cada negocio guarda las suyas en la base.
  • Paso 6: Verificar el conjunto

Ejecuta: npm run typecheck && npm run test:platform Esperado: typecheck sin errores; las 49 pruebas existentes siguen pasando.


Tarea 2: Esquema de credenciales por negocio

Archivos:

  • Crear: platform/db/migrations/003_multitenant.sql
  • Modificar: platform/test/schema.test.ts

Interfaces:

  • Consume: nada.
  • Produce: columnas crm_connections.token_cipher, token_nonce, token_tag, token_fingerprint, token_updated_at, calendar_id, test_email, allow_real_sends, label.

Por qué las columnas de la red de seguridad de mensajes se mudan aquí: hoy CRM_TEST_EMAIL es global (platform/routes/messages.ts:26-34). Con varios negocios, un solo interruptor decidiría por todos: o se abren los envíos reales para todos a la vez, o ninguno puede salir de pruebas. Es una decisión que pertenece a cada cuenta.

  • Paso 1: Escribir la prueba que falla

Añade a platform/test/schema.test.ts:

test("crm_connections guarda la credencial cifrada y los ajustes por negocio", async () => {
  await resetDb();
  const cols = await pool.query<{ column_name: string; is_nullable: string }>(
    `SELECT column_name, is_nullable FROM information_schema.columns
      WHERE table_name = 'crm_connections'`
  );
  const nombres = cols.rows.map((r) => r.column_name);
  for (const c of [
    "token_cipher", "token_nonce", "token_tag", "token_fingerprint",
    "token_updated_at", "calendar_id", "test_email", "allow_real_sends", "label",
  ]) {
    assert.ok(nombres.includes(c), `falta la columna ${c}`);
  }
});

test("allow_real_sends nace en false: los envíos reales se abren a propósito", async () => {
  await resetDb();
  const b = await crearNegocio();
  const { rows } = await pool.query(
    `INSERT INTO crm_connections (business_id, location_id) VALUES ($1, 'loc-1')
     RETURNING allow_real_sends`,
    [b.id]
  );
  assert.equal(rows[0].allow_real_sends, false);
});

crearNegocio() ya existe en platform/test/helpers.ts. Si su firma no encaja, usa el mismo INSERT INTO businesses que usan las demás pruebas de ese archivo.

  • Paso 2: Correr y verificar que falla

Ejecuta: npm run test:platform Esperado: FALLA con «falta la columna token_cipher».

  • Paso 3: Escribir la migración
-- platform/db/migrations/003_multitenant.sql
-- ---------------------------------------------------------------------------
-- De un negocio con un token global, a N negocios con credencial propia.
--
-- Hasta aquí el `location_id` era por negocio pero el token vivía en
-- `CRM_TOKEN`, una variable del proceso (platform/crm/client.ts). Con dos
-- negocios eso usa el token del primero contra la subcuenta del segundo: 401 en
-- el mejor caso, escritura en la subcuenta equivocada en el peor.
--
-- El token se guarda CIFRADO con AES-256-GCM (platform/lib/crypto.ts). La clave
-- maestra vive en CRM_MASTER_KEY, fuera de la base: quien consiga un volcado de
-- Postgres no consigue los tokens.
-- ---------------------------------------------------------------------------

ALTER TABLE crm_connections
  ADD COLUMN token_cipher      bytea,
  ADD COLUMN token_nonce       bytea,
  ADD COLUMN token_tag         bytea,
  -- Los 6 últimos caracteres. Permite decir en la interfaz «termina en …f4a2c1»
  -- y detectar una rotación sin exponer nunca la credencial.
  ADD COLUMN token_fingerprint text,
  ADD COLUMN token_updated_at  timestamptz,
  -- MEDIDO (hallazgo 29): la subcuenta tiene 7 calendarios y las citas reales
  -- están en «Servicio Spa». Sin fijar cuál, empujar una cita al calendario del
  -- CRM sería adivinar.
  ADD COLUMN calendar_id       text,
  -- La red de seguridad de mensajes pasa a ser POR NEGOCIO. Como variable global
  -- decidía por todas las cuentas a la vez.
  ADD COLUMN test_email        text,
  ADD COLUMN allow_real_sends  boolean NOT NULL DEFAULT false,
  -- Nombre legible de la subcuenta, para que el superadministrador no tenga que
  -- reconocer cuentas por un identificador opaco.
  ADD COLUMN label             text;

-- La credencial va completa o no va: media credencial produce un descifrado que
-- falla en tiempo de petición, y eso es un fallo lejos de su causa.
ALTER TABLE crm_connections
  ADD CONSTRAINT crm_connections_credencial_completa CHECK (
    (token_cipher IS NULL AND token_nonce IS NULL AND token_tag IS NULL)
    OR
    (token_cipher IS NOT NULL AND token_nonce IS NOT NULL AND token_tag IS NOT NULL)
  );

COMMENT ON COLUMN crm_connections.token_cipher IS
  'Token privado de la subcuenta, cifrado con AES-256-GCM. Nunca se devuelve por la API.';
  • Paso 4: Aplicar y correr

Ejecuta: npm run pg:migrate && npm run test:platform Esperado: [migrate] aplicada 003_multitenant.sql; las pruebas nuevas pasan y las 49 anteriores siguen pasando.


Tarea 3: CrmCtx — el contexto de credenciales

Archivos:

  • Crear: platform/crm/ctx.ts
  • Crear: platform/test/crmCtx.test.ts
  • Modificar: platform/crm/connection.ts

Interfaces:

  • Consume: cifrar, descifrar, huella de platform/lib/crypto.ts (Tarea 1); las columnas de la Tarea 2.
  • Produce:
    • interface CrmCtx { businessId: number; locationId: string; token: string }
    • ctxDe(businessId: number): Promise<CrmCtx> — lanza { status: 409, error } si no hay conexión o no hay credencial.
    • guardarCredencial(businessId: number, locationId: string, token: string, label?: string): Promise<void>
    • CrmConnection gana token_fingerprint, calendar_id, test_email, allow_real_sends, label.

Por qué un objeto y no dos parámetros sueltos: hoy locationId: string viaja por once firmas. Añadir token: string al lado significa once sitios donde se pueden cruzar dos cadenas del mismo tipo sin que el compilador diga nada. Un objeto con nombres los hace imposibles de intercambiar.

  • Paso 1: Escribir la prueba que falla
// platform/test/crmCtx.test.ts
import { test } from "node:test";
import assert from "node:assert/strict";
import { pool } from "../db/pool.ts";
import { resetDb, crearNegocio } from "./helpers.ts";
import { ctxDe, guardarCredencial } from "../crm/ctx.ts";

process.env.CRM_MASTER_KEY = Buffer.alloc(32, 3).toString("base64");

test("dos negocios tienen credenciales distintas y no se cruzan", async () => {
  await resetDb();
  const a = await crearNegocio({ name: "Spa A" });
  const b = await crearNegocio({ name: "Spa B" });

  await guardarCredencial(a.id, "loc-AAA", "token-de-A", "Spa A");
  await guardarCredencial(b.id, "loc-BBB", "token-de-B", "Spa B");

  const ctxA = await ctxDe(a.id);
  const ctxB = await ctxDe(b.id);

  assert.equal(ctxA.locationId, "loc-AAA");
  assert.equal(ctxA.token, "token-de-A");
  assert.equal(ctxB.locationId, "loc-BBB");
  assert.equal(ctxB.token, "token-de-B");
});

test("el token no queda en claro en la base", async () => {
  await resetDb();
  const a = await crearNegocio();
  await guardarCredencial(a.id, "loc-1", "token-secretisimo");
  const { rows } = await pool.query(
    `SELECT token_cipher, token_fingerprint FROM crm_connections WHERE business_id = $1`,
    [a.id]
  );
  assert.ok(!rows[0].token_cipher.toString("utf8").includes("token-secretisimo"));
  assert.equal(rows[0].token_fingerprint, "etisimo".slice(-6));
});

test("volver a guardar rota la credencial sin duplicar la fila", async () => {
  await resetDb();
  const a = await crearNegocio();
  await guardarCredencial(a.id, "loc-1", "token-viejo");
  await guardarCredencial(a.id, "loc-1", "token-nuevo");
  const ctx = await ctxDe(a.id);
  assert.equal(ctx.token, "token-nuevo");
  const { rows } = await pool.query(
    `SELECT count(*)::int AS n FROM crm_connections WHERE business_id = $1`,
    [a.id]
  );
  assert.equal(rows[0].n, 1);
});

test("un negocio sin conexión da un 409 que dice qué hacer", async () => {
  await resetDb();
  const a = await crearNegocio();
  await assert.rejects(() => ctxDe(a.id), (e: any) => {
    assert.equal(e.status, 409);
    assert.match(e.error, /no está vinculado/i);
    return true;
  });
});
  • Paso 2: Correr y verificar que falla

Ejecuta: npm run test:platform Esperado: FALLA con «Cannot find module '../crm/ctx.ts'».

  • Paso 3: Implementar
// platform/crm/ctx.ts
import { pool } from "../db/pool.ts";
import { cifrar, descifrar, huella } from "../lib/crypto.ts";

/**
 * Todo lo que hace falta para hablar con la subcuenta de UN negocio.
 *
 * Sustituye al `locationId: string` suelto que antes viajaba por once firmas.
 * Es un objeto y no dos parámetros a propósito: dos `string` seguidos se pueden
 * cruzar sin que el compilador diga nada, y cruzarlos aquí significa mandar el
 * token de un cliente a la subcuenta de otro.
 *
 * Es el ÚNICO sitio donde el token existe descifrado, y solo en memoria.
 */
export interface CrmCtx {
  businessId: number;
  locationId: string;
  token: string;
}

export async function ctxDe(businessId: number): Promise<CrmCtx> {
  const { rows } = await pool.query(
    `SELECT location_id, token_cipher, token_nonce, token_tag
       FROM crm_connections WHERE business_id = $1`,
    [businessId]
  );
  const c = rows[0];
  if (!c) {
    throw { status: 409, error: "Este negocio no está vinculado a Bucéfalo CRM" };
  }
  if (!c.token_cipher) {
    throw {
      status: 409,
      error: "Este negocio no tiene token de Bucéfalo CRM. Vincúlalo desde la consola de administración.",
    };
  }
  return {
    businessId,
    locationId: c.location_id,
    token: descifrar({ cipher: c.token_cipher, nonce: c.token_nonce, tag: c.token_tag }),
  };
}

/** Guarda o rota la credencial. Idempotente por negocio. */
export async function guardarCredencial(
  businessId: number,
  locationId: string,
  token: string,
  label?: string
): Promise<void> {
  const c = cifrar(token);
  await pool.query(
    `INSERT INTO crm_connections
       (business_id, location_id, token_cipher, token_nonce, token_tag,
        token_fingerprint, token_updated_at, label)
     VALUES ($1,$2,$3,$4,$5,$6, now(), $7)
     ON CONFLICT (business_id) DO UPDATE SET
       location_id       = EXCLUDED.location_id,
       token_cipher      = EXCLUDED.token_cipher,
       token_nonce       = EXCLUDED.token_nonce,
       token_tag         = EXCLUDED.token_tag,
       token_fingerprint = EXCLUDED.token_fingerprint,
       token_updated_at  = now(),
       label             = COALESCE(EXCLUDED.label, crm_connections.label)`,
    [businessId, locationId, c.cipher, c.nonce, c.tag, huella(token), label ?? null]
  );
}
  • Paso 4: Correr y verificar que pasa

Ejecuta: npm run test:platform Esperado: las 4 pruebas nuevas pasan.


Tarea 4: El token deja de ser global en el cliente HTTP

Archivos:

  • Modificar: platform/crm/client.ts
  • Crear: platform/crm/client.test.ts

Interfaces:

  • Consume: nada.
  • Produce: CrmOptions.token pasa de opcional a obligatorio; el estrangulador pasa a ser por token.

Por qué obligatorio y no con valor por defecto: con el ?? requireEnv("CRM_TOKEN") actual, olvidar el token no da error — usa el del entorno, que pertenece a otro cliente. Al hacerlo obligatorio, el olvido es un error de compilación en npm run typecheck, que es el gate real de calidad del repo. Es el cambio de mayor rendimiento de todo el plan.

Por qué el estrangulador debe ser por token: client.ts:42 guarda let ultimaPeticion a nivel de módulo, con 650 ms de separación. El límite del CRM es por token, así que un semáforo único serializa negocios que podrían ir en paralelo: con diez cuentas, la sincronización de la décima espera a las nueve anteriores sin ninguna razón.

  • Paso 1: Escribir la prueba que falla
// platform/crm/client.test.ts
import { test } from "node:test";
import assert from "node:assert/strict";
import { esperaDeToken, registrarPeticion, MIN_INTERVAL_MS } from "./client.ts";

test("el estrangulador cuenta por token, no globalmente", () => {
  const ahora = 1_000_000;
  registrarPeticion("token-A", ahora);
  // El mismo token tiene que esperar…
  assert.ok(esperaDeToken("token-A", ahora + 10) > 0);
  // …pero otro token no espera nada: su límite es independiente.
  assert.equal(esperaDeToken("token-B", ahora + 10), 0);
});

test("pasado el intervalo, el mismo token deja de esperar", () => {
  const ahora = 2_000_000;
  registrarPeticion("token-C", ahora);
  assert.equal(esperaDeToken("token-C", ahora + MIN_INTERVAL_MS + 1), 0);
});
  • Paso 2: Correr y verificar que falla

Ejecuta: node --import tsx --test platform/crm/client.test.ts Esperado: FALLA — esos tres símbolos no se exportan.

  • Paso 3: Implementar

En platform/crm/client.ts, sustituye el bloque del estrangulador (líneas ~13 y ~42-49):

/** El CRM estrangula a ~1 petición cada 0.65 s POR TOKEN. */
export const MIN_INTERVAL_MS = 650;

// 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.
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, MIN_INTERVAL_MS - (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);
}

Haz el token obligatorio en las opciones:

export interface CrmOptions {
  /** Token privado de la subcuenta. Obligatorio: sin valor por defecto, el
   *  compilador impide mandar la credencial de un cliente a la subcuenta de
   *  otro. Viene de `CrmCtx.token`. */
  token: string;
  body?: unknown;
  version?: string;
  query?: Record<string, string | number | undefined>;
}

Y en crmRequest, sustituye la línea del token y la llamada al estrangulador:

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;
  // …resto igual, pero:
  //   await throttle(token);     en vez de   await throttle();

Borra el import { requireEnv } si queda sin uso; deja loadEnv (sigue leyendo CRM_BASE_URL).

  • Paso 4: Correr la prueba nueva

Ejecuta: node --import tsx --test platform/crm/client.test.ts Esperado: 2 pruebas, 2 pass.

  • Paso 5: Ver la superficie que el compilador reclama

Ejecuta: npm run typecheck Esperado: falla a propósito, con un error por cada una de las 16 llamadas a crmRequest que ya no pasan token, en connection.ts (2), contacts.ts (4), messages.ts (3), opportunities.ts (7). Esa lista es exactamente el trabajo de la Tarea 5. Anótala antes de seguir.


Tarea 5: Propagar CrmCtx por los módulos del CRM

Archivos:

  • Modificar: platform/crm/contacts.ts, platform/crm/messages.ts, platform/crm/opportunities.ts, platform/crm/connection.ts, platform/crm/syncContacts.ts, platform/crm/syncAppointments.ts, platform/crm/outbox.ts
  • Modificar: platform/crm/contacts.test.ts, platform/crm/opportunities.test.ts

Interfaces:

  • Consume: CrmCtx y ctxDe (Tarea 3); CrmOptions.token obligatorio (Tarea 4).
  • Produce, con estas firmas exactas — las demás tareas dependen de ellas:
    • buscarContactos(ctx: CrmCtx, opts?: { pageLimit?: number; searchAfter?: unknown[] })
    • obtenerContacto(ctx: CrmCtx, id: string)
    • buscarPorIdentificador(ctx: CrmCtx, q: string)
    • resolverContacto(ctx: CrmCtx, datos: DatosContacto)
    • buscarConversaciones(ctx: CrmCtx, opts?: { limit?: number; startAfterDate?: number })
    • mensajesDeConversacion(ctx: CrmCtx, conversationId: string, limit?: number)
    • enviarCorreo(ctx: CrmCtx, e: EnvioCorreo)
    • obtenerOportunidad(ctx: CrmCtx, id: string)
    • oportunidadesDeContacto(ctx: CrmCtx, contactId: string)
    • upsertOportunidad(ctx: CrmCtx, args: { … })
    • autoconfigurar(ctx: CrmCtx): Promise<CrmConnection>

Regla mecánica: ctx es siempre el primer parámetro, y el locationId suelto que hoy reciben algunas de estas funciones desaparece — sale de ctx.locationId. Toda llamada a crmRequest gana token: ctx.token.

  • Paso 1: Cambiar contacts.ts

Cuatro funciones y cuatro llamadas. Ejemplo del patrón, aplícalo a las cuatro:

// ANTES
export async function obtenerContacto(id: string): Promise<CrmContact | null> {
  const r = await crmRequest<any>("GET", `/contacts/${id}`);
  ...

// DESPUÉS
export async function obtenerContacto(ctx: CrmCtx, id: string): Promise<CrmContact | null> {
  const r = await crmRequest<any>("GET", `/contacts/${id}`, { token: ctx.token });
  ...

En buscarContactos y buscarPorIdentificador, el locationId que hoy es parámetro pasa a ser ctx.locationId dentro del cuerpo. En resolverContacto, el locationId del cuerpo del POST /contacts/ también sale de ctx.locationId.

  • Paso 2: Cambiar messages.ts, opportunities.ts y connection.ts

Mismo patrón. En connection.ts, autoconfigurar(businessId, locationId) pasa a autoconfigurar(ctx: CrmCtx), deja de llamar loadEnv() y deja de escribir location_id (ya lo escribió guardarCredencial): su INSERT … ON CONFLICT se reduce a actualizar pipeline, etapas y allow_duplicate_opp.

  • Paso 3: Cambiar los tres que orquestan

syncContacts.ts, syncAppointments.ts y outbox.ts reciben businessId y hoy llaman a las funciones del CRM sin credencial. Cada uno obtiene el contexto una sola vez, al principio:

import { ctxDe } from "./ctx.ts";

export async function sincronizarContactos(businessId: number, opts: {...}) {
  const ctx = await ctxDe(businessId);
  // …y a partir de aquí, `ctx` a cada llamada del CRM

En outbox.ts, despachar(businessId, limite) obtiene el ctx una vez antes del bucle: pedirlo por fila descifraría el token en cada iteración sin ganar nada.

  • Paso 4: Actualizar las pruebas existentes

platform/crm/contacts.test.ts y opportunities.test.ts prueban funciones puras (mapAtribucion, nombreDe, estadoOportunidad, nombreOportunidad), que no cambian. Si alguna prueba llama a una función de red, dale un ctx de mentira:

const CTX = { businessId: 1, locationId: "loc-test", token: "token-test" };
  • Paso 5: Verificar

Ejecuta: npm run typecheck && npm run test:platform Esperado: typecheck limpio (era el objetivo del paso 5 de la Tarea 4) y las pruebas en verde.


Tarea 6: Superficie de superadministrador

Archivos:

  • Modificar: platform/lib/auth.ts
  • Crear: platform/routes/admin.ts
  • Modificar: platform/index.ts
  • Crear: platform/test/admin.test.ts

Interfaces:

  • Consume: guardarCredencial, ctxDe (Tarea 3); crmRequest (Tarea 4).
  • Produce: adminOnly middleware; endpoints GET/POST /api/admin/businesses, PATCH /api/admin/businesses/:id, PUT /api/admin/businesses/:id/crm, DELETE /api/admin/businesses/:id/crm.

Por qué se validan las credenciales antes de guardarlas: guardar un token sin comprobarlo traslada el fallo al primer intento de sincronizar, lejos de donde se cometió. Se llama GET /locations/{locationId} con el token recibido: si la subcuenta no responde o no coincide, se rechaza con un mensaje que dice cuál de las dos cosas falló. Es la misma regla de «no aceptar un 200 como prueba» aplicada al alta.

  • Paso 1: Escribir la prueba que falla
// platform/test/admin.test.ts
import { test } from "node:test";
import assert from "node:assert/strict";
import { resetDb, crearUsuario, peticion } from "./helpers.ts";

test("un owner no puede entrar a la consola de administración", async () => {
  await resetDb();
  const owner = await crearUsuario({ role: "owner" });
  const r = await peticion("GET", "/api/admin/businesses", { as: owner });
  assert.equal(r.status, 403);
});

test("el superadministrador da de alta una cuenta con su dueña", async () => {
  await resetDb();
  const admin = await crearUsuario({ role: "admin", business_id: null });
  const r = await peticion("POST", "/api/admin/businesses", {
    as: admin,
    body: {
      name: "Spa Nuevo",
      timezone: "America/Mexico_City",
      owner_email: "[email protected]",
      owner_name: "Dueña",
      owner_password: "demo1234",
    },
  });
  assert.equal(r.status, 201);
  assert.equal(r.body.business.name, "Spa Nuevo");
  assert.ok(r.body.business.slug, "el negocio debe nacer con slug");
  assert.ok(r.body.owner.id);
});

test("el listado nunca devuelve el token, solo su huella", async () => {
  await resetDb();
  const admin = await crearUsuario({ role: "admin", business_id: null });
  const creada = await peticion("POST", "/api/admin/businesses", {
    as: admin,
    body: { name: "Spa", owner_email: "[email protected]", owner_name: "A", owner_password: "x" },
  });
  await guardarCredencial(creada.body.business.id, "loc-1", "token-secretisimo");
  const r = await peticion("GET", "/api/admin/businesses", { as: admin });
  const texto = JSON.stringify(r.body);
  assert.ok(!texto.includes("token-secretisimo"), "el token no puede salir por la API");
  assert.ok(texto.includes("etisimo".slice(-6)), "sí debe salir la huella");
});

Si peticion() y crearUsuario() no existen aún en platform/test/helpers.ts, escríbelos ahí siguiendo el patrón que ya usan platform/test/clients.test.ts y appointments.test.ts para montar la app y autenticarse.

  • Paso 2: Correr y verificar que falla

Ejecuta: npm run test:platform Esperado: FALLA con 404 en /api/admin/businesses.

  • Paso 3: Añadir adminOnly

En platform/lib/auth.ts, junto a ownerOnly:

/**
 * Administradora de plataforma: opera todas las cuentas y su `business_id` es
 * NULL. No se confunde con `owner`, que manda dentro de UN negocio.
 */
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();
}
  • Paso 4: Escribir el router
// platform/routes/admin.ts
import { Router } from "express";
import { pool, withTx } from "../db/pool.ts";
import { adminOnly, err, h, type AuthedRequest } from "../lib/auth.ts";
import { writeAudit } from "../lib/audit.ts";
import { guardarCredencial } from "../crm/ctx.ts";
import { crmRequest } from "../crm/client.ts";
import { DEFAULT_WORKING_HOURS, uniqueSlugPg } from "../lib/businessDefaults.ts";

export const adminRouter = Router();
adminRouter.use(adminOnly);

/** Las cuentas de la plataforma, con el estado de su vínculo con Bucéfalo CRM. */
adminRouter.get(
  "/businesses",
  h(async (_req: AuthedRequest, res) => {
    const { rows } = await pool.query(
      `SELECT b.id, b.name, b.slug, b.timezone, b.status, b.created_at,
              c.location_id, c.label AS crm_label, c.token_fingerprint,
              c.token_updated_at, c.pipeline_id, c.calendar_id,
              c.allow_real_sends, c.last_sync_at, c.last_sync_status,
              (SELECT count(*) FROM clients cl
                WHERE cl.business_id = b.id AND cl.deleted_at IS NULL)::int AS clientes
         FROM businesses b
         LEFT JOIN crm_connections c ON c.business_id = b.id
        ORDER BY b.created_at DESC`
    );
    // `token_fingerprint` sale; `token_cipher` no se selecciona siquiera.
    res.json({ businesses: rows });
  })
);

/** Alta de cuenta: negocio + su dueña, en una transacción. */
adminRouter.post(
  "/businesses",
  h(async (req: AuthedRequest, res) => {
    const { name, timezone, owner_email, owner_name, owner_password, industry } = req.body ?? {};
    if (!name || !owner_email || !owner_name || !owner_password) {
      err(res, 400, "Faltan el nombre del negocio y los datos de la dueña");
      return;
    }

    const creado = await withTx(async (tx) => {
      const slug = await uniqueSlugPg(tx, String(name));
      const { rows: bs } = await tx.query(
        `INSERT INTO businesses (name, industry, timezone, slug, working_hours)
         VALUES ($1, $2, $3, $4, $5) RETURNING *`,
        [name, industry || "Estética y Spa", timezone || "America/Mexico_City", slug,
         DEFAULT_WORKING_HOURS]
      );
      const business = bs[0];
      const { rows: us } = await tx.query(
        `INSERT INTO users (business_id, email, password, name, role)
         VALUES ($1, $2, $3, $4, 'owner')
         RETURNING id, email, name, role`,
        [business.id, String(owner_email).toLowerCase(), owner_password, owner_name]
      );
      await writeAudit(tx, {
        businessId: business.id,
        actorUserId: req.user!.id,
        entity: "businesses",
        entityId: business.id,
        action: "create",
        after: { name: business.name, slug: business.slug },
        ip: req.ip ?? null,
      });
      return { business, owner: us[0] };
    });

    res.status(201).json(creado);
  })
);

/**
 * Vincula la cuenta a su subcuenta de Bucéfalo CRM.
 *
 * Se COMPRUEBAN las credenciales antes de guardarlas: un token que no se valida
 * traslada el fallo al primer intento de sincronizar, lejos de donde se cometió.
 */
adminRouter.put(
  "/businesses/:id/crm",
  h(async (req: AuthedRequest, res) => {
    const businessId = Number(req.params.id);
    const { location_id, token, label } = req.body ?? {};
    if (!location_id || !token) {
      err(res, 400, "Hacen falta el identificador de la subcuenta y el token privado");
      return;
    }

    const { rows } = await pool.query(`SELECT id FROM businesses WHERE id = $1`, [businessId]);
    if (!rows[0]) {
      err(res, 404, "La cuenta no existe");
      return;
    }

    let nombreSubcuenta: string | null = null;
    try {
      const loc = await crmRequest<any>("GET", `/locations/${location_id}`, { token });
      nombreSubcuenta = loc?.location?.name ?? null;
    } catch (e: any) {
      if (e?.status === 401) {
        err(res, 400, "El token no es válido para Bucéfalo CRM o ha caducado");
        return;
      }
      if (e?.status === 404) {
        err(res, 400, "Ese identificador de subcuenta no existe, o el token no da acceso a ella");
        return;
      }
      err(res, 502, `Bucéfalo CRM no respondió: ${e?.message ?? e}`);
      return;
    }

    await guardarCredencial(businessId, String(location_id), String(token), label || nombreSubcuenta || undefined);
    await withTx((tx) =>
      writeAudit(tx, {
        businessId,
        actorUserId: req.user!.id,
        entity: "crm_connections",
        entityId: businessId,
        action: "link",
        // Deliberadamente NO se audita el token, ni cifrado.
        after: { location_id, label: label || nombreSubcuenta },
        ip: req.ip ?? null,
      })
    );

    res.json({ ok: true, location_id, label: label || nombreSubcuenta });
  })
);

/** Desvincula: borra la credencial pero conserva los datos ya sincronizados. */
adminRouter.delete(
  "/businesses/:id/crm",
  h(async (req: AuthedRequest, res) => {
    const businessId = Number(req.params.id);
    await pool.query(
      `UPDATE crm_connections
          SET token_cipher = NULL, token_nonce = NULL, token_tag = NULL,
              token_fingerprint = NULL, token_updated_at = NULL
        WHERE business_id = $1`,
      [businessId]
    );
    res.json({ ok: true });
  })
);

DEFAULT_WORKING_HOURS y uniqueSlugPg no existen todavía en platform/. Crea platform/lib/businessDefaults.ts con la misma constante de horario que server/lib/businessDefaults.ts y un uniqueSlugPg(tx, nombre) que use el mismo slugify pero consulte Postgres. No importes desde server/: los dos backends no comparten código a propósito.

  • Paso 5: Montar el router

En platform/index.ts, junto a los demás:

import { adminRouter } from "./routes/admin.ts";
// …
app.use("/api/admin", authRequired, adminRouter);
  • Paso 6: Verificar

Ejecuta: npm run typecheck && npm run test:platform Esperado: todo en verde, incluidas las 3 pruebas nuevas de administración.


Tarea 7: Las rutas de negocio dejan de leer el entorno

Archivos:

  • Modificar: platform/routes/crm.ts
  • Modificar: platform/routes/messages.ts

Interfaces:

  • Consume: ctxDe (Tarea 3), autoconfigurar(ctx) (Tarea 5).

  • Produce: GET /api/crm/status gana token_fingerprint, label y allow_real_sends; nunca devuelve el token.

  • Paso 1: POST /api/crm/connect deja de usar process.env

En platform/routes/crm.ts, la ruta /connect (línea ~54) hoy toma req.body?.location_id || process.env.CRM_LOCATION_ID. Cámbiala para que exija que la cuenta ya esté vinculada por el superadministrador, y se limite a redetectar pipeline y etapas:

crmRouter.post(
  "/connect",
  ownerOnly,
  h(async (req: AuthedRequest, res) => {
    const bid = req.user!.business_id!;
    // Las credenciales las pone la administración de la plataforma, no el
    // negocio: son de la subcuenta del cliente y no deben viajar por aquí.
    const ctx = await ctxDe(bid);          // lanza 409 si no está vinculado
    const c = await autoconfigurar(ctx);
    // …auditoría igual que antes
    res.json({ connection: { ...c, token_cipher: undefined } });
  })
);
  • Paso 2: GET /api/crm/status expone la huella, nunca el token

Añade al res.json de /status:

      label: conexion.label,
      token_fingerprint: conexion.token_fingerprint,
      token_updated_at: conexion.token_updated_at,
      allow_real_sends: conexion.allow_real_sends,

Y revisa que obtenerConexion no siga haciendo SELECT *: cámbialo a una lista explícita de columnas que excluya token_cipher, token_nonce y token_tag. ctxDe es el único que los lee.

  • Paso 3: La red de seguridad de envíos pasa a ser por negocio

En platform/routes/messages.ts, sustituye destinoPermitido(deseado) (línea ~26) por una versión que reciba la conexión:

/**
 * MODO PRUEBA — por negocio, no global.
 *
 * Mientras la conexión tenga `test_email` y `allow_real_sends` en false, el
 * servidor solo escribe a esa dirección. Como variable de entorno global esto
 * decidía por todas las cuentas a la vez: o se abrían los envíos reales para
 * todas, o ninguna salía de pruebas.
 */
function destinoPermitido(
  conexion: { test_email: string | null; allow_real_sends: boolean },
  deseado: string
): { to: string; forzado: boolean } {
  if (conexion.test_email && !conexion.allow_real_sends) {
    return {
      to: conexion.test_email,
      forzado: conexion.test_email.toLowerCase() !== deseado.toLowerCase(),
    };
  }
  return { to: deseado, forzado: false };
}

Y en las tres rutas de ese archivo, sustituye buscarConversaciones(conexion.location_id, …), mensajesDeConversacion(…) y enviarCorreo(…) por sus versiones con ctx (Tarea 5).

  • Paso 4: Verificar

Ejecuta: npm run typecheck && npm run test:platform Esperado: verde.

  • Paso 5: Comprobar que el token no se filtra por ninguna ruta

Ejecuta:

grep -rn "token_cipher\|token_nonce\|token_tag" platform/routes/ platform/crm/syncContacts.ts platform/crm/syncAppointments.ts

Esperado: cero resultados. Esas tres columnas solo pueden aparecer en platform/crm/ctx.ts y en la migración.


Tarea 8: Migrar el negocio existente y verificar contra la subcuenta real

Archivos:

  • Crear: platform/scripts/crm-migrar-credencial.ts
  • Modificar: platform/scripts/crm-conectar.ts

Interfaces:

  • Consume: guardarCredencial (Tarea 3), ctxDe, autoconfigurar(ctx) (Tarea 5).

  • Produce: nada que otras tareas consuman.

  • Paso 1: Escribir el script de migración

// platform/scripts/crm-migrar-credencial.ts
/**
 * Mueve la credencial de `platform/.env` a la base, cifrada, para el negocio
 * que ya estaba vinculado. Es de un solo uso: después, las credenciales se
 * ponen desde la consola de administración.
 *
 *   node scripts/run-tsx.mjs platform/scripts/crm-migrar-credencial.ts <businessId>
 */
import { pool } from "../db/pool.ts";
import { requireEnv } from "../lib/env.ts";
import { guardarCredencial, ctxDe } from "../crm/ctx.ts";
import { crmRequest } from "../crm/client.ts";

const businessId = Number(process.argv[2]);
if (!Number.isFinite(businessId)) {
  console.error("Uso: … crm-migrar-credencial.ts <businessId>");
  process.exit(1);
}

const locationId = requireEnv("CRM_LOCATION_ID");
const token = requireEnv("CRM_TOKEN");

const loc = await crmRequest<any>("GET", `/locations/${locationId}`, { token });
const nombre = loc?.location?.name ?? null;
console.log(`Subcuenta: ${nombre} (${locationId})`);

await guardarCredencial(businessId, locationId, token, nombre ?? undefined);

// No se acepta el guardado como prueba: se relee y se usa.
const ctx = await ctxDe(businessId);
const rel = await crmRequest<any>("GET", `/locations/${ctx.locationId}`, { token: ctx.token });
console.log(
  rel?.location?.id === locationId
    ? `✔ Credencial guardada y verificada releyendo para el negocio ${businessId}`
    : `✖ La relectura no coincide`
);
await pool.end();
  • Paso 2: Correr contra el entorno real

Ejecuta:

node scripts/run-tsx.mjs platform/scripts/crm-migrar-credencial.ts 1

Esperado: imprime el nombre de la subcuenta y ✔ Credencial guardada y verificada releyendo.

  • Paso 3: Comprobar que el token ya no está en claro en la base
docker exec yola-postgres psql -U yola -d yola -c "SELECT business_id, location_id, token_fingerprint, length(token_cipher) FROM crm_connections;"

Esperado: una fila con token_fingerprint de 6 caracteres y length distinto de 0. La columna del token en claro no existe.

  • Paso 4: Sincronizar de verdad, con la credencial de la base

Con el servidor levantado (npm run platform), entra como la dueña y pulsa el botón de sincronizar contactos, o llama a POST /api/crm/sync/contacts. Esperado: la corrida termina en ok con ~3 210 contactos. Es la prueba de que el token descifrado funciona de punta a punta.

  • Paso 5: Verificar el aislamiento con dos cuentas

Crea una segunda cuenta desde POST /api/admin/businesses y vincúlala con un location_id inventado y un token cualquiera. Esperado: la vinculación se rechaza con «Ese identificador de subcuenta no existe, o el token no da acceso a ella». Es la comprobación de que la validación previa de la Tarea 6 hace su trabajo.


Tarea 9: La consola del superadministrador en el frontend

Archivos:

  • Modificar: shared/types.ts
  • Modificar: src/lib/api.ts
  • Crear: src/pages/admin/PlatformAccountsPage.tsx
  • Modificar: src/App.tsx

Interfaces:

  • Consume: los endpoints de la Tarea 6.

  • Produce: ruta /admin/cuentas.

  • Paso 1: Tipos compartidos

En shared/types.ts:

/** Una cuenta de la plataforma vista desde la consola de administración. */
export interface PlatformAccount {
  id: number;
  name: string;
  slug: string | null;
  timezone: string;
  status: string;
  created_at: string;
  clientes: number;
  /** Vínculo con Bucéfalo CRM. Null si la cuenta no está vinculada. */
  location_id: string | null;
  crm_label: string | null;
  /** Los 6 últimos caracteres del token. El token nunca sale del servidor. */
  token_fingerprint: string | null;
  token_updated_at: string | null;
  pipeline_id: string | null;
  calendar_id: string | null;
  allow_real_sends: boolean | null;
  last_sync_at: string | null;
  last_sync_status: string | null;
}
  • Paso 2: Métodos de API

En src/lib/api.ts, junto a los grupos existentes:

  admin: {
    accounts: () => request<{ businesses: PlatformAccount[] }>("/admin/businesses"),
    createAccount: (b: {
      name: string; owner_email: string; owner_name: string; owner_password: string;
      timezone?: string; industry?: string;
    }) => request<{ business: { id: number }; owner: { id: number } }>("/admin/businesses", {
      method: "POST", body: JSON.stringify(b),
    }),
    linkCrm: (id: number, b: { location_id: string; token: string; label?: string }) =>
      request<{ ok: true; location_id: string; label: string | null }>(
        `/admin/businesses/${id}/crm`, { method: "PUT", body: JSON.stringify(b) }
      ),
    unlinkCrm: (id: number) =>
      request<{ ok: true }>(`/admin/businesses/${id}/crm`, { method: "DELETE" }),
  },
  • Paso 3: La pantalla

Crea src/pages/admin/PlatformAccountsPage.tsx con: una tabla de cuentas (nombre, clientas, estado del vínculo con el CRM mostrando crm_label y «token …{token_fingerprint}», última sincronización), un botón «Nueva cuenta» que abra un modal con los cinco campos del alta, y por fila un botón «Vincular CRM» que abra un modal con location_id, token y label.

Tres reglas de interfaz que no son opcionales:

  1. El campo del token es type="password" y nunca se rellena con un valor existente: no hay valor existente que traer, el servidor no lo devuelve. Cuando ya hay vínculo, el modal dice «Guardar un token nuevo reemplaza el actual».
  2. El estado de error muestra el mensaje que devuelve el servidor, no uno fijo: los mensajes de la Tarea 6 distinguen «token inválido» de «subcuenta inexistente», y esa diferencia es justo lo que quien vincula necesita saber.
  3. Etiqueta htmlFor en los cinco campos, con su id en el control. El resto del panel no lo hace y es una deuda registrada; no la aumentes.
  • Paso 4: Ruta

En src/App.tsx, dentro del AdminShell:

<Route path="cuentas" element={<PlatformAccountsPage />} />
  • Paso 5: Verificar

Ejecuta: npm run typecheck && npm run build Esperado: sin errores.

Y a mano, contra npm run platform con el frontend apuntado a :3100: entra como administración de plataforma, da de alta una cuenta, vincúlala con las credenciales reales, y comprueba que la tabla muestra la huella del token y nunca el token.


Autorrevisión

Cobertura del objetivo. El encargo pedía cuatro cosas. Alta de cuentas por el superadministrador → Tarea 6 (endpoints) y Tarea 9 (pantalla). Vincular por locationId y token privado → Tareas 1-3 (cifrado y almacenamiento), Tarea 6 (validación previa contra el CRM). Que cada negocio use lo suyo → Tareas 4-5, con el compilador como garantía. Migrar lo que ya existe sin romperlo → Tarea 8. La sincronización por id de las cinco entidades no está aquí a propósito: es el plan hermano, y depende de CrmCtx, que produce este.

Placeholders. Revisado: no hay «TBD», ni «añadir manejo de errores», ni «similar a la Tarea N». Los mensajes de error están escritos literalmente. La única indirección deliberada es el Paso 3 de la Tarea 9, que describe una pantalla en vez de dictarla: el repo ya tiene cuatro pantallas de administración cuyo patrón hay que seguir, y copiarlo aquí produciría divergencia.

Consistencia de tipos. CrmCtx se define en la Tarea 3 y se consume con el mismo nombre y la misma forma en las Tareas 4, 5, 7 y 8. guardarCredencial(businessId, locationId, token, label?) tiene la misma firma en las Tareas 3, 6 y 8. token_fingerprint se llama igual en la migración (Tarea 2), la consulta (Tarea 6), el tipo compartido y la pantalla (Tarea 9).

Un hueco que dejo dicho, no escondido: la autenticación sigue siendo el token trivial —el id del usuario en claro— también para el rol admin. Este plan añade una consola que crea cuentas y guarda credenciales de clientes encima de esa base. Endurecer la sesión es un entregable propio y declarado en la deuda del repo; mientras no se haga, esta consola no debe quedar expuesta a internet. Conviene decidirlo antes de desplegar, no después.