Multi-tenancy con credenciales por negocio y sincronizacion por id #1

Open
urieljareth wants to merge 3 commits from feat/multitenant-crm into main
Owner

Convierte el backend Postgres de platform/ en una plataforma multi-cuenta y añade la sincronización selectiva de las cinco entidades del encargo.

El problema que resuelve

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.

Multi-tenancy

  • 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 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.
  • crm/client.ts — el token pasa a ser obligatorio en el tipo: olvidarlo es ahora un error de compilación. El estrangulador es por token y aprende la cuota de las cabeceras x-ratelimit-*, que declaran 100 peticiones por 10 s — el cliente iba 6,5× 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 y la pantalla /admin/cuentas: 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».

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, no quien llama:

Entidad Dirección Por qué
contacto, conversación, mensaje ← del CRM Es su dueño: ahí viven la deduplicación y las automatizaciones
cita, servicio → al CRM Medido: el calendario del CRM tiene una cita en dos años y su catálogo está vacío

El espejo persistido de conversaciones y mensajes también es nuevo: las tablas existían desde 002_crm.sql y nadie escribía en ellas.

Verificado contra la subcuenta real, no deducido

Las cinco entidades se ejercieron contra el CRM del cliente. Las escrituras van en 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 que este PR construye.
  • 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.
Convierte el backend Postgres de `platform/` en una plataforma multi-cuenta y añade la sincronización selectiva de las cinco entidades del encargo. ## El problema que resuelve 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. ## Multi-tenancy - **`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` 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. - **`crm/client.ts`** — el token pasa a ser **obligatorio en el tipo**: olvidarlo es ahora un error de compilación. El estrangulador es por token y aprende la cuota de las cabeceras `x-ratelimit-*`, que declaran 100 peticiones por 10 s — el cliente iba 6,5× 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` y la pantalla `/admin/cuentas`: 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». 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, no quien llama: | Entidad | Dirección | Por qué | |---|---|---| | contacto, conversación, mensaje | ← del CRM | Es su dueño: ahí viven la deduplicación y las automatizaciones | | cita, servicio | → al CRM | Medido: el calendario del CRM tiene **una** cita en dos años y su catálogo está **vacío** | El espejo persistido de conversaciones y mensajes también es nuevo: las tablas existían desde `002_crm.sql` y **nadie escribía en ellas**. ## Verificado contra la subcuenta real, no deducido Las cinco entidades se ejercieron contra el CRM del cliente. Las escrituras van en 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 que este PR construye. - 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.**
urieljareth added 2 commits 2026-08-30 21:09:13 +00:00
El VOLUME anónimo del Dockerfile y el volumen con nombre de Coolify son dos piezas
del mismo mecanismo: quitar una y no poner la otra deja la base efímera. Queda
documentado junto con el respaldo diario y por qué usa VACUUM INTO en vez de cp.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
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]>
urieljareth added 1 commit 2026-08-30 21:33:39 +00:00
Prepara el backend Postgres para producción y lo despliega como stack de Swarm
en el servidor de Consultoría E3, detrás de Traefik.

## Lo que le faltaba al proyecto para poder empaquetarse

- **`platform/` importaba de `server/`.** `routes/dayClose.ts` traía los helpers
  de zona horaria de `../../server/lib/time.ts`, contradiciendo lo que el propio
  README declara. El typecheck pasaba limpio porque en el equipo de desarrollo
  el archivo existe; solo se vio al contenerizar, cuando el proceso murió al
  arrancar con ERR_MODULE_NOT_FOUND. Ahora hay `platform/lib/time.ts`, copia
  deliberada —misma decisión que `businessDefaults.ts`— y una prueba que impide
  que vuelva a colarse una importación cruzada.
- **No había endpoint de salud.** `/api/health` consulta la base: uno que solo
  responde `{ok:true}` sigue en verde con Postgres caído, justo cuando el
  orquestador debería reiniciar.
- **No servía el frontend.** Ahora sirve `dist/` con fallback de SPA que excluye
  `/api/`, para que una ruta de API inexistente devuelva 404 y no el index.
- **No había apagado ordenado.** Sin él, cada redespliegue corta las peticiones
  en vuelo. Verificado: la tarea vieja registra el SIGTERM y cierra.

## Imagen

`platform/Dockerfile`, multi-etapa. Corre como `USER node`, fija `TZ=UTC` —la
agenda saca la zona de `businesses.timezone`, y dejar la del proceso en México
haría que una recaída pasara desapercibida— y **no** ejecuta las migraciones al
arrancar: se lanzan como paso explícito del despliegue, porque hacerlo al inicio
deja el esquema a medias si el arranque falla.

`tsx` pasa de devDependencies a dependencies: este backend corre TypeScript
directo y arrancar con `npx tsx` lo bajaría de npm en cada arranque, sin versión
fijada y sobre todo el código del servidor.

## Estado en el servidor

- Base `agendapro` con rol `agendapro_app` de mínimo privilegio, `PUBLIC`
  revocado. Verificado: el rol no ve ninguna tabla de las otras bases.
- Clave maestra de cifrado **generada en el servidor**, nunca copiada de
  desarrollo.
- Stack `agendapro` desplegado, 1/1 réplicas, 5 migraciones aplicadas, 17 tablas.
- Salud, SPA y API de administración verificadas desde dentro de la red overlay.

## Pendiente y bloqueante para el acceso externo

El A-record `agendapro.consultoriae3.com` -> 157.173.205.217 hay que crearlo a
mano en SiteGround. Sin él no hay certificado, porque Let's Encrypt valida por
HTTP-01.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
You are not authorized to merge this pull request.
This pull request can be merged automatically.
View command line instructions

Checkout

From your project repository, check out a new branch and test the changes.
git fetch -u origin feat/multitenant-crm:feat/multitenant-crm
git checkout feat/multitenant-crm
Sign in to join this conversation.
No Reviewers
No labels
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: urieljareth/AgendaPro#1