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()),
|
|
|
|
|
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)}`,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
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;
|
|
|
|
|
}
|