2026-10-13 18:29:13 -05:00
import { existsSync } from 'node:fs' ;
import { resolve } from 'node:path' ;
2026-10-13 18:13:49 -05:00
import { z } from 'zod' ;
2026-10-13 18:29:13 -05:00
const emptyToUndefined = ( v : unknown ) => ( v === '' ? undefined : v );
export const envSchema = z
. object ({
NODE_ENV : z.enum ([ 'development' , 'production' , 'test' ]). default ( 'development' ),
PORT : z.coerce.number (). int (). positive (). default ( 3000 ),
// Orígenes permitidos por CORS, separados por coma. Vacío = mismo origen.
CORS_ORIGINS : z
. string ()
. default ( '' )
. transform (( v ) =>
v
. split ( ',' )
. map (( s ) => s . trim ())
. filter ( Boolean ),
),
LOG_LEVEL : z.enum ([ 'error' , 'warn' , 'log' , 'debug' , 'verbose' ]). default ( 'log' ),
2026-10-13 18:47:51 -05:00
// Base SQLite local (permisos de menú). `:memory:` para pruebas.
SQLITE_PATH : z.string (). min ( 1 ). default ( './data/app.sqlite' ),
// Usuarios web (U_WEB_USER) que siempre tienen acceso total ("*"); se
// garantiza al arrancar para no quedar fuera por un error de permisos.
ADMIN_WEB_USERS : z
. string ()
. default ( '' )
. transform (( v ) =>
v
. split ( ',' )
. map (( s ) => s . trim ())
. filter ( Boolean ),
),
2026-10-13 18:38:11 -05:00
// Inactividad tras la cual caduca la sesión web (deslizante).
SESSION_TIMEOUT_MINUTES : z.coerce.number (). int (). positive (). default ( 30 ),
2026-10-13 18:29:13 -05:00
// --- SAP Business One Service Layer ---
SAPB1_SL_URL : z
. url ()
. transform (( v ) => v . replace ( /\/+$/ , '' )),
SAPB1_SL_COMPANYDB : z.string (). min ( 1 ),
SAPB1_SL_USER : z.string (). min ( 1 ),
SAPB1_SL_PASSWORD : z.string (). min ( 1 ),
SAPB1_SL_TIMEOUT_MS : z.coerce.number (). int (). positive (). default ( 15000 ),
// Peticiones simultáneas permitidas hacia el Service Layer; el resto se encola.
2026-10-13 18:38:11 -05:00
// Medido en SAP10 (12 peticiones): con 1 -> 0.6 s en total; con 2 ya tarda 7.5 s y con
// 4, 22 s (el Service Layer retrasa ~7 s por cada petición paralela extra). Default: 1.
2026-10-13 18:29:13 -05:00
SAPB1_SL_MAX_CONCURRENCY : z.coerce.number (). int (). positive (). default ( 1 ),
// CA adicional de confianza (PEM): certificado autofirmado del Service
// Layer o CA de un firewall que inspecciona SSL. Opcional.
SAPB1_SL_CA_FILE : z.preprocess ( emptyToUndefined , z . string (). optional ()),
2026-10-19 17:46:09 -05:00
// --- SAP Business One API Gateway (reportes Crystal): opcional; sin URL el módulo queda sin configurar ---
APIGATEWAY_URL : z.preprocess ( emptyToUndefined , z . url (). optional ()). transform (( v ) => v ? . replace ( /\/+$/ , '' )),
APIGATEWAY_USER : z.preprocess ( emptyToUndefined , z . string (). optional ()),
APIGATEWAY_PASSWORD : z.preprocess ( emptyToUndefined , z . string (). optional ()),
APIGATEWAY_COMPANYDB : z.preprocess ( emptyToUndefined , z . string (). optional ()),
APIGATEWAY_DBINSTANCE : z.preprocess ( emptyToUndefined , z . string (). optional ()),
// Espera máxima (segundos) de cada llamada: generar un reporte puede tardar.
APIGATEWAY_TIMEOUT : z.coerce.number (). positive (). default ( 60 ),
// Reportes que se generan a la vez; el resto espera su turno.
APIGATEWAY_MAX_CONCURRENCY : z.coerce.number (). int (). positive (). default ( 2 ),
APIGATEWAY_CA_FILE : z.preprocess ( emptyToUndefined , z . string (). optional ()),
APIGATEWAY_VERIFY_SSL : z
. enum ([ 'true' , 'false' ])
. default ( 'true' )
. transform (( v ) => v === 'true' ),
2026-10-20 07:31:17 -05:00
// --- Base de datos de SAP B1 (consultas de solo lectura registradas): opcional; sin motor el módulo queda sin configurar ---
SAPDB_ENGINE : z.preprocess ( emptyToUndefined , z . enum ([ 'hana' ]). optional ()),
SAPDB_HOST : z.preprocess ( emptyToUndefined , z . string (). optional ()),
SAPDB_PORT : z.coerce.number (). int (). min ( 1 ). max ( 65535 ). default ( 30015 ),
SAPDB_USER : z.preprocess ( emptyToUndefined , z . string (). optional ()),
SAPDB_PASSWORD : z.preprocess ( emptyToUndefined , z . string (). optional ()),
// Esquema de la empresa (en HANA, el nombre de la base: `SBO_TAGUMEDICA_21`).
SAPDB_SCHEMA : z.preprocess ( emptyToUndefined , z . string (). regex ( /^\w+$/ , 'solo letras, dígitos y «_»' ). optional ()),
SAPDB_TLS : z
. enum ([ 'true' , 'false' ])
. default ( 'false' )
. transform (( v ) => v === 'true' ),
SAPDB_CA_FILE : z.preprocess ( emptyToUndefined , z . string (). optional ()),
SAPDB_VERIFY_SSL : z
. enum ([ 'true' , 'false' ])
. default ( 'true' )
. transform (( v ) => v === 'true' ),
// Espera máxima (segundos) de cada consulta.
SAPDB_TIMEOUT : z.coerce.number (). positive (). default ( 30 ),
// Consultas que se ejecutan a la vez; el resto espera su turno (la base de SAP no es para esto).
SAPDB_MAX_CONCURRENCY : z.coerce.number (). int (). positive (). default ( 2 ),
// Tope de filas de cualquier consulta; lo que pase se recorta.
SAPDB_MAX_ROWS : z.coerce.number (). int (). positive (). default ( 5000 ),
2026-10-13 18:29:13 -05:00
SAPB1_SL_VERIFY_SSL : z
. enum ([ 'true' , 'false' ])
. default ( 'true' )
. transform (( v ) => v === 'true' ),
})
. superRefine (( env , ctx ) => {
if ( env . SAPB1_SL_CA_FILE && ! existsSync ( resolve ( env . SAPB1_SL_CA_FILE ))) {
ctx . addIssue ({
code : 'custom' ,
path : [ 'SAPB1_SL_CA_FILE' ],
message : `no existe el archivo ${ resolve ( env . SAPB1_SL_CA_FILE ) } ` ,
});
}
2026-10-19 17:46:09 -05:00
if ( env . APIGATEWAY_CA_FILE && ! existsSync ( resolve ( env . APIGATEWAY_CA_FILE ))) {
ctx . addIssue ({ code : 'custom' , path : [ 'APIGATEWAY_CA_FILE' ], message : `no existe el archivo ${ resolve ( env . APIGATEWAY_CA_FILE ) } ` });
}
2026-10-20 07:31:17 -05:00
if ( env . SAPDB_CA_FILE && ! existsSync ( resolve ( env . SAPDB_CA_FILE ))) {
ctx . addIssue ({ code : 'custom' , path : [ 'SAPDB_CA_FILE' ], message : `no existe el archivo ${ resolve ( env . SAPDB_CA_FILE ) } ` });
}
if ( env . SAPDB_ENGINE && ! ( env . SAPDB_HOST && env . SAPDB_USER && env . SAPDB_PASSWORD && env . SAPDB_SCHEMA )) {
ctx . addIssue ({ code : 'custom' , path : [ 'SAPDB_ENGINE' ], message : 'con SAPDB_ENGINE hacen falta SAPDB_HOST, SAPDB_USER, SAPDB_PASSWORD y SAPDB_SCHEMA' });
}
if ( env . NODE_ENV === 'production' && env . SAPDB_TLS && ! env . SAPDB_VERIFY_SSL ) {
ctx . addIssue ({ code : 'custom' , path : [ 'SAPDB_VERIFY_SSL' ], message : 'no se permite desactivar la validación SSL en producción; use SAPDB_CA_FILE' });
}
2026-10-19 17:46:09 -05:00
if ( env . APIGATEWAY_URL && ! ( env . APIGATEWAY_USER && env . APIGATEWAY_PASSWORD && env . APIGATEWAY_COMPANYDB )) {
ctx . addIssue ({ code : 'custom' , path : [ 'APIGATEWAY_URL' ], message : 'con APIGATEWAY_URL hacen falta APIGATEWAY_USER, APIGATEWAY_PASSWORD y APIGATEWAY_COMPANYDB' });
}
if ( env . NODE_ENV === 'production' && ! env . APIGATEWAY_VERIFY_SSL ) {
ctx . addIssue ({ code : 'custom' , path : [ 'APIGATEWAY_VERIFY_SSL' ], message : 'no se permite desactivar la validación SSL en producción; use APIGATEWAY_CA_FILE' });
}
2026-10-13 18:29:13 -05:00
if ( env . NODE_ENV === 'production' && ! env . SAPB1_SL_VERIFY_SSL ) {
ctx . addIssue ({
code : 'custom' ,
path : [ 'SAPB1_SL_VERIFY_SSL' ],
message : 'no se permite desactivar la validación SSL en producción; use SAPB1_SL_CA_FILE' ,
});
}
});
2026-10-13 18:13:49 -05:00
export type Env = z . infer < typeof envSchema >;
export function validateEnv ( raw : Record < string , unknown >) : Env {
const result = envSchema . safeParse ( raw );
if ( ! result . success ) {
const detail = result . error . issues
. map (( i ) => ` ${ i . path . join ( '.' ) } : ${ i . message } ` )
. join ( '; ' );
throw new Error ( `Configuración inválida (.env): ${ detail } ` );
}
return result . data ;
}