# 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** ```ts // 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** ```ts // 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: ```bash # 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: ```bash # 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`: ```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** ```sql -- 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` — lanza `{ status: 409, error }` si no hay conexión o no hay credencial. - `guardarCredencial(businessId: number, locationId: string, token: string, label?: string): Promise` - `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** ```ts // 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** ```ts // 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 { 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 { 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** ```ts // 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): ```ts /** 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(); /** 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: ```ts 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; } ``` Y en `crmRequest`, sustituye la línea del token y la llamada al estrangulador: ```ts export async function crmRequest( method: string, path: string, opts: CrmOptions ): Promise { 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` **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: ```ts // ANTES export async function obtenerContacto(id: string): Promise { const r = await crmRequest("GET", `/contacts/${id}`); ... // DESPUÉS export async function obtenerContacto(ctx: CrmCtx, id: string): Promise { const r = await crmRequest("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: ```ts 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: ```ts 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** ```ts // 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: "duena@spanuevo.mx", 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: "a@b.mx", 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`: ```ts /** * 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** ```ts // 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("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: ```ts 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: ```ts 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`: ```ts 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: ```ts /** * 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: ```bash 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** ```ts // 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 */ 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 "); process.exit(1); } const locationId = requireEnv("CRM_LOCATION_ID"); const token = requireEnv("CRM_TOKEN"); const loc = await crmRequest("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("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: ```bash 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** ```bash 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`: ```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: ```ts 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`: ```tsx } /> ``` - [ ] **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.