mogos propuesta · v1.0 · jul 2026

Andy · la empresa como filtro

El filtro que debería aislarlo lo escribe su navegador.

David lo dio por resuelto: «Andy vería todo filtrado de China, por ruta, y la plataforma la vería en chino». Ninguna de las dos cosas es cierta hoy. El filtro de ruta es una cookie que el navegador escribe — se cambia en dos clics. Y del chino faltan 168 claves y hay 192 sin traducir. Andy no puede leer la pantalla donde genera el documento que acabamos de diseñarle.

Claves que faltan en chino
168 · más 192 sin traducir
Dónde vive su idioma
En el localStorage del navegador
Notificaciones en español fijo
10 sitios del API
Campos de preferencia en User
Cero
02La empresa no es lo que miras
es quién eres

Ahí está toda la diferencia. El corredor es una preferencia: hoy miro China, mañana Estados Unidos. La empresa es una propiedad de la persona: Andy pertenece a la entidad de China, y eso no es un interruptor que él alterna.

Así que se reusa el modelo mental del corredor —un filtro alto que ordena todo lo de abajo— y se le cambia la fuente de verdad:

Corredor · hoyEmpresa · propuesto
De dónde sale el valor La cookie del navegador Las membresías del usuario, resueltas en el servidor desde CompanyUser, junto a permissionSet
¿Lo manda el cliente? Sí, como parámetro Nunca. Un valor que llegue del cliente se ignora
¿Se puede vaciar? Sí — «sin ruta específica» No. Si perteneces a una empresa, no existe la opción «todas»
Qué filtraLa vistaLas filas
El selector Siempre visible, con todas las rutas Solo ofrece lo que eres. Andy pertenece a una: no ve selector

Es la misma tubería y el mismo hábito para quien la usa. Lo único que cambia es que el conjunto de valores permitidos lo decide el servidor, no el navegador. Por eso no se puede quitar: no hay nada que quitar.

03Filas, nunca acciones
el detalle que produce las fugas
El bypass de MANAGER está en cuatro sitios

permissions.guard.ts:80 · super-admin.guard.ts:34 · can-access.util.ts:34 · mcp/can-use-tool.ts:39. Los cuatro dicen lo mismo: isSuperAdmin === true || role === MANAGERsalta la matriz de permisos completa.

Si la empresa se implementa como «un permiso más», ese bypass la salta también. Y basta con que alguien de la entidad de China sea MANAGER para que el aislamiento desaparezca sin que nadie lo note.

Son dos preguntas ortogonales y hay que mantenerlas separadas:

El permiso decide qué puedes hacer

Leer, crear, actualizar, borrar. Ahí el bypass de MANAGER es deliberado y se queda como está.

La empresa decide sobre qué filas

Un MANAGER de la entidad de China puede hacer todo — sobre las filas de China. El bypass no toca el alcance, y el alcance no toca el bypass.

04Lo que NO se filtra
el fallo más probable

Casi todo el mundo diseña esto pensando en la fuga. El fallo más probable es el contrario: que Andy entre y vea una plataforma vacía, y concluya que está rota. Y como el filtro no se puede quitar, él no tiene forma de comprobar si algo existe. Por eso esta lista deja de ser un detalle y pasa a ser carga estructural.

Se filtra por empresaNunca se filtra
Cotizaciones de servicio · prefacturas · pagos · órdenes · fletes · contenedores · productos y ventas Datos de referencia: países, corredores, almacenes, ciudades, métodos de pago, tipos de cambio, tarifas
Lo que lleva dinero o carga El maestro de proveedores — es conocimiento compartido a propósito: Pedro y Jesús se recomiendan fábricas, y partirlo destruiría el histórico de precios que acabamos de diseñar
El propio usuario y su sesión, o no puede ni entrar

Y una consecuencia directa de que el filtro no se pueda quitar: companyId no puede ser anulable. Una fila en NULL o la ve todo el mundo o no la ve nadie — el error clásico de este tipo de filtro. Va NOT NULL después del relleno, para que ese estado no pueda existir.

05Quien pertenece a una, no puede ver otra
ni borrando cookies

La respuesta no hay que inventarla: este repo ya escribió la regla, en el interceptor que permite que una persona sea a la vez personal y cliente.

«Porque actuar como cliente solo puede ESTRECHAR el acceso… lo peor que logra un atacante falsificando esta cabecera es ver menos de sus propios datos. En el momento en que eso deje de ser cierto —el primer branch que conceda algo— esta cabecera tiene que pasar a formar parte del token.»

apps/api/src/auth/interceptors/client-scope.interceptor.ts

Elegir empresa concede acceso a otro conjunto de datos. Así que la pregunta se parte en dos, y cada mitad tiene una fuente distinta:

La preguntaDe dónde sale la respuesta
¿A qué empresas puedo ver?
concede
El servidor, siempre. Se resuelve de CompanyUser en JwtStrategy.validate, junto a permissionSet. Un valor que llegue del cliente se responde con 403, no se ignora en silencio — porque o es un bug o es un intento, y las dos cosas hay que verlas.
¿Cuál de las mías estoy mirando?
estrecha
Una cookie, como el corredor. Es seguro precisamente porque solo puede estrechar dentro de lo que el servidor ya autorizó. Lo peor que logras falsificándola es ver menos de lo tuyo.
Y por eso Andy no tiene nada que falsificar

Si perteneces a una empresa, estrechar dentro de un conjunto de un elemento da siempre el mismo elemento. Borrar las cookies, editarlas, mandar otro identificador por parámetro: todos los caminos terminan en China, porque el conjunto no viene del navegador.

El selector, cuando perteneces a varias

Una empresa · Andy, Pedro, casi todos

No hay selector. No es que esté deshabilitado: no se dibuja. El alcance es un hecho, no una elección, y una elección de una sola opción es ruido.

Varias · tú, Samuel

Un selector con exactamente tus empresas, más «Consolidado» — la palabra que el switcher de finanzas ya usa para «todas». Por defecto abre en Consolidado: si tienes las dos, normalmente quieres el cuadro completo.

Y para que sea imposible de verdad, no solo correcto

El diseño de arriba es correcto, pero se aplica con cláusulas where en muchos servicios — y basta con que una ruta nueva lo olvide. Hay tres niveles de garantía, y conviene elegir a sabiendas:

NivelQué garantiza · qué cuesta
Disciplina + guarda Cada servicio aplica el alcance y lint:company-scope revienta la compilación si la regla se escribe dos veces. Bueno, pero no hermético: una consulta nueva dentro de un servicio existente puede pasar.
Extensión de Prisma
recomendado
Una capa que inyecta el filtro en toda consulta a los modelos declarados como alcanzados. El desarrollador no puede olvidarlo porque nunca lo escribe. Y la lista de modelos alcanzados vive en un archivo auditable en vez de repartida en veinte servicios. Hoy el repo no tiene ninguna extensión ni middleware de Prisma, así que sería la primera — un solo sitio que revisar.
Seguridad a nivel de fila en Postgres La base de datos misma se niega. Es lo más fuerte que existe. Pero exige una variable de sesión por petición a través del pool de conexiones, y con el pooler que usa esta plataforma eso es delicado. Para el modelo de amenaza real —personal de una empresa hermana, no un atacante con acceso SQL— es desproporcionado.
El agujero que hay que nombrar

Una extensión de Prisma no cubre las consultas crudas. Este repo tiene varias — las búsquedas por nombre usan $queryRaw con f_unaccent para poder apoyarse en el índice. Esas hay que acotarlas a mano, y son exactamente el tipo de sitio donde se olvida.

Así que la guarda no sobra aunque haya extensión: se ocupa de lo que la extensión no ve. Las dos, no una.

06La guarda, en el mismo commit
el repo ya sabe por qué

Un filtro global se aplica en muchos servicios, y basta con que una ruta nueva lo olvide para que filtre todo. Este repo ya se quemó con eso y dejó escrita la lección — el encabezado de check-client-scope.ts:

«Falla la compilación cuando la regla de “¿esta petición es de cliente?” tiene una segunda copia. Una comparación de rol suelta parte la regla en dos, y las dos copias se desfasan — el mismo fallo que produjo un controlador de pagos sin decorador de matriz, una barra lateral que mostraba enlaces que el guardia negaba, y un feed de acciones que le entregaba conversaciones al personal de almacén.»

Tres fugas reales, causadas por la misma regla escrita dos veces. La empresa merece exactamente el mismo trato: un solo predicado y un lint:company-scope que reviente la compilación cuando aparezca una segunda copia, en el mismo commit que la migración — no después.

07El idioma sigue a la persona
hoy sigue al navegador
Claves de admin en español5 016
Existen también en chino4 850
Faltan en chino168
Existen pero sin traducir · [TRANSLATE]192
base traducido hueco real

Y el hueco no está repartido: el bloque serviceQuotes.products no existe en chino. Es la pestaña de productos y proveedores — justo donde Andy trabajaría.

Su idioma vive en su navegador

User no tiene ni un campo de preferencia: cero. El idioma se guarda en localStorage con la clave mogos-admin-language.

Computadora nueva, español. Se arregla con una columna: User.locale.

Y el API no pregunta

10 sitios de producción mandan language: 'es' fijo. Así que cada notificación que Andy dispare sale en español aunque su pantalla esté en chino.

Con User.locale guardado, esos 10 sitios leen el idioma del destinatario en vez de suponerlo.

La cadena, y por qué se resuelve al leer

idioma = User.locale            // lo que la persona eligió
      ?? Company.defaultUiLocale  // el de su empresa · siembra al PERSONAL
      ?? 'es'                     // el de la plataforma

Se resuelve al leer, no se copia al crear

User.locale nace nulo, y nulo significa algo honesto: «esta persona todavía no ha elegido». Si mañana cambias el idioma por defecto de la entidad de China, todo el que nunca eligió lo sigue — sin migración ni relleno.

Copiarlo al dar de alta congelaría el valor y obligaría a un backfill cada vez que cambie.

El default de empresa siembra al personal, no a los clientes

Es la trampa. Los clientes de Andy son venezolanos — David lo dijo. Darle una interfaz en chino a un cliente venezolano solo porque lo factura la entidad de China sería un error inmediato y visible.

Para un cliente la cadena es más corta: User.locale ?? 'es'. La empresa que le factura no dice nada sobre el idioma en que lee.

Y no confundir los dos ejes

Company.defaultLocale ya existe en el proyecto de cotizaciones, pero es del documentoES o EN_ZH, según quién lee la proforma. Este es otro eje: la pantalla, es · en · zh, según quién trabaja.

Un chino con la interfaz en chino puede y debe emitir un documento en español a un cliente venezolano. Fusionarlos haría imposible esa combinación, que es justo la más frecuente. Se llama defaultUiLocale y vive aparte.

Dónde se enchufa

HoyDespués
Detección ['localStorage','navigator','htmlTag'] El idioma resuelto viaja en /users/me, que ya devuelve el perfil y los permisos. localStorage pasa de ser la verdad a ser una caché para que la primera pintura no parpadee.
Elegir idioma solo toca el navegador Escribe la caché y guarda User.locale. Computadora nueva, mismo idioma.
10 sitios con language: 'es' Resuelven el idioma del destinatario, no el de quien dispara. Es el error fácil: cuando Andy manda una proforma a un cliente venezolano, la notificación va en español — porque se lee del que recibe.

Y hace falta una guarda, o el chino se vuelve a pudrir. Que inglés y chino tengan ~190 [TRANSLATE] cada uno es la prueba de que ya pasó dos veces. Una clave nueva sin traducción tiene que romper la compilación, igual que el alcance.

08Lo que ve Andy hoy
con el idioma puesto en chino

El menú está completamente traducido — las 39 etiquetas, en chino real. Así que la historia no es «la plataforma está en español». Es que el menú lo lleva hasta la pantalla donde trabaja, y ahí se cae.

mogos CN → VE 中文A
运营
操作中心
报价
供应商
订单
货运
财务
收款
支付
客户
客户
代理
报价 · COT-20260727-0014
COT-20260727-0014
摘要和操作 [TRANSLATE] Productos solicitados [TRANSLATE] Cotización [TRANSLATE] Pagos [TRANSLATE] Historial
Productos solicitados & proveedores
ProveedorMarca Precio U.Días
Shenzhen YitaiA elegir820.0028
Guangzhou SunLenovo910.0025
+ Agregar proveedor
chino real · 4 850 claves el token [TRANSLATE] impreso · 192 cae a español · 168 que no existen

El token se imprime, no se repliega

Las 192 claves existen en el archivo chino — con [TRANSLATE] dentro del valor. i18next encuentra la clave y pinta el valor tal cual.

Andy no ve español ahí: ve [TRANSLATE] Cotización en su barra de pestañas. Es peor que no traducir — parece que el programa está roto.

Y el bloque de productos no existe

Las 168 que faltan sí caen a español, porque el repliegue apunta a es. Y no están repartidas al azar: serviceQuotes.products no existe entero.

Es la pestaña de productos y proveedores — exactamente donde Andy pasaría el día.

Y la barra superior, según quién eres

mogos CN → VE 中文A
Andy · una empresa · no hay selector. No está deshabilitado: no se dibuja.
mogos CN → VE Consolidado ⌄ ESC
· dos empresas · las tuyas más Consolidado, la palabra que finanzas ya usa.

Traducir 5 000 claves es la respuesta perezosa. El hueco está concentrado: el menú está completo y lo que se rompe es un módulo. Hace falta medir qué toca Andy de verdad y cerrar eso — más una guarda que impida que la próxima pantalla nueva vuelva a nacer con [TRANSLATE] dentro.

09Qué NO hacemos
y por qué
×

Un tercer selector en la barra

Ya hay dos —corredor y entidad de finanzas— y el propio código documenta el dolor de que choquen. El selector de empresa solo aparece si perteneces a más de una: Andy no ve ninguno, y tampoco Pedro.

×

Meter la empresa en la matriz de permisos

El bypass de MANAGER la saltaría. Permiso es qué puedes hacer; empresa es sobre qué filas. Juntarlas es la fuga.

×

Aceptar la empresa como parámetro o cabecera

El repo ya tiene la regla escrita para el caso del cliente: una cabecera es segura solo mientras estreche el acceso. Elegir empresa concede acceso a otro conjunto de datos, así que va resuelta en el servidor.

×

Filtrar los proveedores por empresa

Es conocimiento compartido a propósito — Pedro y Jesús se recomiendan fábricas. Partirlo destruiría el histórico de precios que acabamos de diseñar.

×

Traducir a mano y confiar en la disciplina

Inglés y chino tienen ~190 [TRANSLATE] cada uno. Ya se intentó dos veces. Sin una guarda que rompa la compilación, la tercera termina igual.

×

Traducir los documentos con el i18n de la app

La proforma ya se resolvió aparte, con etiquetas fijas EN / 中文 — el documento lo leen dos audiencias a la vez y no depende del idioma de quien lo genera.

10Qué se puede romper
y cómo lo sabemos
RiesgoArnés
Filtrar de más Es el fallo más probable y el más silencioso: Andy ve una plataforma vacía y cree que está rota. Prueba de humo por rol: entrar como Andy y verificar que cada pantalla del menú devuelve algo — o dice explícitamente por qué no. La lista blanca de datos de referencia es parte de la migración, no un ajuste posterior.
Una ruta nueva que olvide el alcance lint:company-scope en el mismo commit. El repo ya tiene el precedente y la lista de las tres fugas que causó no tenerlo.
El relleno de companyId Todo lo existente es de Mogos Venezuela. Se rellena, se verifica que no quedó ninguna fila sin empresa, y solo entonces se pone NOT NULL. En ese orden.
Las cotizaciones existentes cambian de dueño Ninguna debería, pero es lo que hay que demostrar: mismo conteo por lista antes y después para un usuario de Venezuela.
El corredor y la empresa se contradicen Andy pertenece a China y su corredor por defecto es CN→VE. Si limpia el corredor debe seguir viendo solo China — son dos filtros, y el de empresa no depende del otro. Prueba explícita.
La traducción llega tarde Andy puede usar la plataforma en inglés mientras tanto — pero inglés tiene 191 [TRANSLATE], así que tampoco está listo. Conviene medir cuánto de lo que Andy realmente toca está traducido, en vez de traducir 5 000 claves que no va a ver.

Mismo filtro que la ruta. Distinta fuente de verdad.

Andy no necesita una plataforma nueva ni un modelo de inquilinos: necesita que el filtro que ya existe deje de venir del navegador y venga de quién es él. Eso, una columna para su idioma, y una guarda que impida que la regla se escriba dos veces. Lo demás es traducción — medible, aburrida y necesaria.

Cambia

La fuente del filtro: del navegador al servidor.

Se agrega

User.locale y companyId NOT NULL.

Se protege

lint:company-scope, en el mismo commit.

Se mide

Cuánto de lo que Andy toca está en chino. Hoy: no todo.

mogos · propuesta de Andy y la empresa · v1.0 Fuente: reunión con David, 26 de julio de 2026 · mapeo de permisos, i18n y alcance