@tuain/form-exchange
v3.1.0
Published
Motor de intercambio de formularios abstractos de la plataforma Tuain: transforma la definición y los datos de un formulario entre el frontend y el backend según las acciones ejecutadas en cada lado. Software propietario.
Maintainers
Readme
@tuain/form-exchange
Motor de intercambio de formularios abstractos de la plataforma Tuain.
SOFTWARE PROPIETARIO. Copyright (c) 2022-2026 Imix Latam / TUAIN. Todos los derechos reservados. Este paquete no es software libre ni de código abierto. Su uso está sujeto a la licencia del archivo LICENSE: licencia de uso no exclusiva, no transferible y revocable, sin derecho a modificar, redistribuir, sublicenciar, descompilar ni aplicar ingeniería inversa.
El acceso público en el registro de npm no otorga licencia de uso. Que
npm installfuncione sin token no convierte a este paquete en permisivo: el derecho de uso proviene únicamente del contrato escrito con el Titular. Si llegaste aquí sin ese contrato, no estás autorizado a usarlo.
Qué es
Transforma la definición y los datos de un formulario abstracto entre el frontend y el backend, aplicando en cada lado las transformaciones que corresponden a la acción ejecutada. Se compone de dos piezas:
FormManager— administra el catálogo de definiciones de formularios, calcula sus firmas y despacha cada acción a la clase de gestión correspondiente.Form— clase base que el servicio consumidor extiende por cada formulario, registrando el comportamiento cononAction(),onFieldValidation(),onTablePopulate()yonTableAction().
El paquete se distribuye como un único bundle CommonJS minificado (dist/index.js) con sus
definiciones de tipos (dist/index.d.ts). El código fuente no se publica.
Instalación
El paquete tiene acceso público, así que no se necesita token de lectura ni .npmrc:
npm install @tuain/form-exchangeconst { FormManager, Form } = require('@tuain/form-exchange');Requiere Node 18 o superior.
Uso
El catálogo: FormManager
const { FormManager } = require('@tuain/form-exchange');
const formManager = new FormManager(
rootPath, // raíz desde la que se resuelven las clases de formulario
formsDefinitions, // catálogo de definiciones, indexado por formCode
cacheManager, // reservado
appLogger, // logger con método log({ level, label, action, message })
errMgr, // gestor de errores con addModuleSet() y get()
formClasses, // opcional: mapa formCode → clase, en lugar de carga por ruta
debugMode, // opcional: si true, el detalle del error expone mensaje y stack
);
const [error, definition] = await formManager.getFormDefinition(requestContext, 'miFormulario');
const [errorAccion, respuesta] = await formManager.executeFormAction(requestContext, 'miFormulario', requestData);Ambos métodos devuelven la tupla [error, resultado]: error es null cuando todo salió bien.
Las clases de formulario se resuelven de dos maneras, y conviene elegir una:
formClasses— un mapa{ formCode: ClaseDelFormulario }. Explícito y compatible con bundlers.- Por ruta — si
formClassesesnull, se cargan conrequiredesdepath.resolve(rootPath, formsDefinitions.formClassDirectory, <archivo>), tomando el nombre del archivo deformsDefinitions.formMapping[formCode].classo deformsDefinitions[formCode].classFile.
El comportamiento: extender Form
const { Form } = require('@tuain/form-exchange');
class MiFormulario extends Form {
constructor(logger, errMgr, requestContext, formDefinition, requestData) {
super(logger, errMgr, requestContext, formDefinition, requestData);
this.onAction('guardar', () => this.guardar());
this.onFieldValidation('documento', (code, value) => this.validarDocumento(code, value));
this.onTablePopulate('resultados', (tableCode) => this.poblarResultados(tableCode));
}
async startOperation() {
this.showField('documento');
this.demandField('documento');
}
async guardar() {
const valores = this.getFieldsValues(['documento', 'nombre']);
// ... lógica del servicio
return null; // null = sin error
}
}
module.exports = MiFormulario;Un manejador devuelve null si todo salió bien, o un objeto de error construido con errMgr.get().
Las acciones start, startOperation, GETDATA, VALIDATE, TABLEACTION y GETTABLEDATA ya
vienen registradas por la clase base: se personalizan sobrescribiendo startOperation() y
getData(), no volviendo a registrarlas.
Datos inmutables: claros y oscuros
Un dato inmutable es estado que el servidor le confía al cliente para que se lo devuelva. Hay dos clases, y la diferencia es qué puede ver el frontend.
Claros (setImmutableElement / getImmutableElement). Viajan como { value, signature }. El
frontend los lee con getImmutableElement(nombre). Sirven para lo que el cliente necesita mostrar y el
servidor necesita recuperar sin volver a consultarlo.
this.setImmutableElement('cuenta', { numero: '123', titular: 'Ana' });
// en la petición siguiente
const cuenta = this.getImmutableElement('cuenta'); // null si fue alteradoOscuros (setDarkImmutableElement / getDarkImmutableElement). Viajan como una sola cadena:
<base64url(JSON del valor)>.<HMAC-SHA256 del base64url>El frontend la recibe, la guarda y la devuelve tal cual; la librería del cliente no expone ningún camino para leer su contenido. Sirven para lo que el cliente no debe ver: identificadores internos, montos de referencia, cualquier cosa cuyo valor no sea asunto del navegador.
this.setDarkImmutableElement('tarifa', { valor: 1500, origen: 'tabla-2024' });
// en la petición siguiente
const tarifa = this.getDarkImmutableElement('tarifa'); // null si no existeEl sello es HMAC con la clave del servidor, no un hash simple: un hash sin clave lo recalcularía cualquiera que edite el contenido, y el mecanismo no detectaría nada.
Un bloque oscuro alterado rechaza la petición completa. Se verifica al importar, no al leerse: la
acción no se ejecuta, ningún manejador del formulario corre, y se devuelve el error 1006
(immutableTampered, HTTP 400). Es distinto de los claros, que devuelven null al leerse y dejan que
el formulario decida. Un oscuro alterado no es un dato dudoso: es evidencia de manipulación.
El sello no depende del orden de las claves
Desde la 3.1.0 el texto que el HMAC cubre es canónico: las claves de cada objeto se ordenan antes de serializar, recursivamente. Los arreglos no se tocan —ahí la posición es dato—.
La razón es que el inmutable viaja por el cliente y vuelve, y en el camino hay capas que lo
reconstruyen sin alterarlo: un puente nativo que parsea la respuesta a un diccionario del sistema y
la vuelve a serializar, una caché que lo rehidrata, un proxy que lo normaliza. El caso que lo obligó fue
un cliente Capacitor con CapacitorHttp activo: en iOS la capa nativa hace JSONSerialization sobre la
respuesta —un diccionario que no tiene orden— y el JavaScript recibe una re-serialización de ese
diccionario. El cliente devolvía fielmente lo recibido, con las claves en otro orden, y todos los
inmutables de tipo objeto se rechazaban en cada ida y vuelta, como si los hubieran manipulado.
Lo que no cambia: un valor alterado, una clave de más y el orden de un arreglo siguen invalidando el sello. Y para un valor escalar el texto es idéntico al de antes, así que solo rotan los sellos de los valores de tipo objeto; los bloques oscuros no rotan ninguno, porque lo que sellan es una cadena base64 —y por eso mismo un inmutable oscuro es inmune a este problema y es la salida rápida cuando un cliente concreto reconstruye el JSON—.
Los sellos que el cliente ya tenía emitidos se siguen aceptando mientras drenan, y el veredicto lo
reporta en legacyOrderAccepted para que se pueda medir cuándo dejaron de llegar. Esa tolerancia se
apaga con TUAIN_FORM_ACCEPT_LEGACY_CONTENT_ORDER=false; no es una concesión de seguridad —se verifica
con la misma clave y sobre el mismo dato— así que apagarla es higiene, no urgencia.
Campos inmutables
Un campo se puede marcar para que su valor lo fije solo el servidor:
this.setFieldValue('monto', montoCalculado);
this.markFieldImmutable('monto');Efecto:
- el campo llega a la respuesta —equivale además a un
touchField— conimmutable: true, y la librería del frontend impide modificarlo (avisa por consola, no lanza); - su valor se sella en el bloque oscuro reservado
_fields, y al volver sobrescribe lo que el cliente haya enviado enformData.fields.
Las dos protecciones son independientes a propósito: la del frontend evita el cambio accidental, y el
sello hace que el cambio deliberado no sirva de nada. Marcar un campo no lo deshabilita ni lo oculta:
inmutable y editable son banderas distintas, y hay campos inmutables que igual deben verse activos. Para
deshabilitarlo, disableField aparte.
El bloque se arma al exportar, así que el orden no importa: markFieldImmutable antes o después de
setFieldValue da lo mismo, y lo que queda sellado es el último valor que puso el servidor. La marca
sobrevive las idas y vueltas sola: no hay que volver a declararla en cada petición. Para liberar el
campo, unmarkFieldImmutable, que lo devuelve con immutable: false para que el frontend lo desbloquee.
El servidor sí puede cambiar el valor de un campo inmutable en cualquier momento: la inmutabilidad rige frente al cliente.
this.isFieldImmutable('monto'); // true
this.getImmutableFieldNames(); // ['monto']
this.markFieldsImmutable(['a', 'b']);_fields es un nombre reservado: setDarkImmutableElement('_fields', …) se rechaza y se registra.
Versiones pareadas
Los oscuros exigen @tuain/ng-forms ≥ 22.2.0 en el frontend. Un frontend anterior no reenvía
darkImmutables, y como el backend no tiene estado no puede notar la ausencia: el bloque se pierde en
cada vuelta y los campos inmutables se quedan sin su valor confiable, en silencio. El protocolo de
intercambio sigue siendo TUAINEXCHANGE_1.0: el cambio es aditivo y un backend anterior ignora el
atributo nuevo sin romperse.
La prueba de integridad de los registros de tabla exige ≥ 22.3.0. Con un frontend anterior la prueba no
vuelve, y con recordProofPolicy: 'enforce' toda acción sobre un registro se rechaza; en 'warn', que es
como se publica, opera igual y solo se registra.
Rastro del recorrido: qué se envió, qué volvió, qué leyó el formulario
Un inmutable que no llega, que vuelve distinto o que el formulario lee como null se manifiesta mucho
más arriba como un campo vacío. Desde la 3.0.5 las tres estaciones del viaje quedan en el log del
servicio bajo la etiqueta immutableTrace, con una action por estación —enviados, recibidos,
entregado, rechazado—, de modo que un grep por el nombre del elemento reconstruye el viaje sin
tocar el código del consumidor.
Lo gobierna la política immutableTrace, por variable de entorno o por inyección:
TUAIN_FORM_IMMUTABLE_TRACE=summary # off (publicado) | summary | fullnew FormManager(root, defs, cache, logger, errMgr, clases, false, {
securityPolicies: { immutableTrace: 'summary' },
});off— nada del recorrido. Es como se publica.summary— inventario por estación y, por elemento, tipo, tamaño, huella, prefijo del sello y si el sello recibido coincide con el esperado. Sin contenido. La huella es un hash corto del contenido: el mismo inmutable la tiene igual al salir y al volver, así que comparar dos líneas responde «¿volvió igual?» sin que el dato aparezca en ninguna parte.full— además, el contenido serializado, el sello completo (selloGeneradoa la salida,selloRecibidoyselloEsperadoa la entrada) y, para los valores de tipo objeto, elcontenidoFirmado: el texto canónico exacto que el HMAC cubre. Es lo que resuelve el caso clásico —un objeto que se reconstruye con las claves en otro orden y deja de verificar sin que nadie lo haya tocado—, porque dos hashes distintos no dicen dónde está la diferencia y dos textos sí.
⚠️
fulles para reproducir un problema, no para dejarlo encendido. Vuelca datos de negocio en el log, y elselloEsperadode un contenido que el cliente acaba de enviar es un sello válido para ese contenido: quien lea el log puede forjar.FormManagerlo advierte enwarnal arrancar.
Los rechazos no dependen de esta política. Un sello que no coincide, un inmutable que llega sin
firma o un bloque mal formado se registran siempre, en error, incluso con el rastro en off: son
evidencia de manipulación, no diagnóstico opcional. La línea dice qué se rechazó, por qué y qué
consecuencia tuvo, pero no vuelca el contenido rechazado —es contenido que el servidor no avala—:
deja su huella, que basta para reconocer el mismo intento repitiéndose.
Prueba de integridad de los registros de tabla
El registro sobre el que opera una acción de tabla viaja por el cliente. Sin prueba, nada lo ata al que el servidor envió: un cliente puede cambiarle el saldo y el callback de negocio opera sobre el dato falseado.
setTableRecords y fillTable emiten en cada fila un recordProof HMAC, y en la tabla la lista
sealedColumns de las columnas que cubre. El cliente devuelve las dos cosas y el servidor recalcula.
Se verifica un subconjunto, no el registro completo, y eso es deliberado: el recordData que vuelve no
es el que se envió ni siquiera sin manipulación —las aplicaciones le agregan contexto, le borran claves del
widget y alternan la columna de selección—, así que exigir igualdad exacta rechazaría flujos vivos.
// El callback recibe el objeto del cliente, adiciones incluidas.
this.onTableAction('creditos', 'aprobar', (tableCode, actionCode, recordId, recordData) => {
// Esto es entrada del cliente, sin garantía:
const filtroDeLaPantalla = recordData.filtroAplicado;
// Esto es lo que la prueba avala; null si no está cubierto o si la prueba no cuadró:
const saldo = this.getTrustedRecordValue('saldo');
if (!this.tableRecordTrusted) {
return this.errMgr.get(/* … */);
}
});La política se publica en 'warn': registra y no rechaza, así que todavía no protege. Ver el CHANGELOG
de la 2.2.0, sección "Cómo activar la protección", y también "Lo que la prueba no resuelve" —replay de un
registro viejo, y que la prueba no autoriza: el callback sigue teniendo que verificar pertenencia—.
Configuración requerida: clave de firma
Los datos inmutables —claros y oscuros— y el sello de los campos inmutables usan todos la misma firma HMAC. Esa firma solo sirve si la clave es un secreto del servidor.
La clave se resuelve en este orden:
requestContext.signatureKeyrequestContext.deviceKey- La variable de entorno
TUAIN_FORM_SIGNATURE_KEY - Una clave heredada que viaja en el paquete publicado y debe considerarse comprometida
Si tu requestContext no puebla las dos primeras —lo habitual en las rutas web—, exporta la
variable:
TUAIN_FORM_SIGNATURE_KEY=<secreto propio, largo y aleatorio>Debe ser el mismo valor en todas las instancias del servicio: el dato inmutable viaja por el cliente y se verifica en cualquiera de ellas. Trátala como secreto de runtime, no la hornees en la imagen.
Mientras no la configures, la librería firma con la clave heredada y registra un error de nivel error
una vez por proceso, con el texto de remediación. Al configurarla, los datos nuevos quedan protegidos de
inmediato y los que el cliente traiga firmados con la clave anterior se siguen aceptando durante la
transición, de modo que desplegar no rompe los formularios en vuelo.
Licencia de ejecución
Desde la 2.0.0 el paquete incluye el mecanismo de verificación de licencia de ejecución, inerte en
esta versión: no verifica nada y no cambia el comportamiento de ningún consumidor. Cuando el Titular
lo active, FormManager exigirá una licencia firmada, que el consumidor publica en la variable de
entorno TUAIN_FORM_LICENSE:
TUAIN_FORM_LICENSE=<token entregado por el Titular>También se puede pasar por opciones al construir el manager, para casos donde el secreto no viaja por entorno:
new FormManager(rootPath, defs, cache, logger, errMgr, formClasses, false, { license: token });La licencia se entrega como secreto de runtime, no horneada en la imagen de contenedor: dentro de la imagen queda atada a su ciclo de vida y renovarla exigiría reconstruir y redesplegar.
Migración desde tuain-form-manager
El paquete cambia de nombre, el require cambia de cadena y ahora se publican tipos donde antes no
había ninguno. El comportamiento en runtime no cambia. El documento de migración lo entrega el Titular
junto con la versión.
Documentación adicional
docs/CONTRATO-CABLE.md— los vectores golden que congelan el contrato de cable con el gateway (firma de peticiones, cookie de sesión y sello por sesión), paso previo a migrar ese kernel a la librería.
Soporte
Titular: Imix Latam / TUAIN — [email protected]
