Ctrl K
ComunidadGuías…

src/config.ts

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
import 'dotenv/config'
import { z } from 'zod'

const configSchema = z.object({
  DATABASE_URL: z.string().url(),
  PORT: z.coerce.number().int().positive().default(3000),
  NODE_ENV: z.enum(['development', 'production', 'test']).default('development'),
  LOG_LEVEL: z.string().default('info'),
  /**
   * Logs a line for every request when true. Off by default: the client
   * evaluation endpoint is polled on a timer by every SDK instance, so a line
   * per request buries the events an operator actually needs and fills the
   * disk of whoever is self-hosting. Failed and slow requests are logged
   * either way. Parsed explicitly because z.coerce.boolean treats any
   * non-empty string as true.
   */
  REQUEST_LOG: z
    .string()
    .optional()
    .transform((v) => v === 'true'),
  CACHE_TTL_SECONDS: z.coerce.number().int().positive().default(30),
  RATE_LIMIT_MAX: z.coerce.number().int().positive().default(100),
  RATE_LIMIT_WINDOW_MS: z.coerce.number().int().positive().default(60_000),

  /** Login and invite routes: they run argon2, so guessing is also a CPU drain. */
  AUTH_RATE_LIMIT_MAX: z.coerce.number().int().positive().default(20),
  AUTH_RATE_LIMIT_WINDOW_MS: z.coerce.number().int().positive().default(900_000),

  /** Off by default: trusting this header with no proxy in front skips the rate limit. */
  TRUST_PROXY: z
    .string()
    .optional()
    .transform((value): boolean | number | string => {
      if (value === undefined || value.trim() === '' || value === 'false') return false
      if (value === 'true') return true
      /** 0 is a hop count too -- it is how an operator spells "trust nothing". */
      const hops = Number(value)
      return Number.isInteger(hops) && hops >= 0 ? hops : value
    }),
  /** Applies pending migrations at startup so a fresh container just works. */
  RUN_MIGRATIONS: z
    .string()
    .optional()
    .transform((v) => v !== 'false'),
  JWT_SECRET: z.string().min(32),
  DEFAULT_ADMIN_EMAIL: z.string().email().default('admin@flagraft.local'),
  DEFAULT_ADMIN_PASSWORD: z.string().min(8).default('flagraft-admin'),
  DEFAULT_ADMIN_NAME: z.string().default('Admin'),
  DEFAULT_PROJECT_NAME: z.string().default('Default'),
  DEFAULT_PROJECT_SLUG: z.string().default('default'),

  /**
   * SMTP settings for emailing user invites. All optional: when SMTP_HOST is
   * unset, email is disabled and invites fall back to manual password sharing.
   */
  SMTP_HOST: z.string().optional(),
  SMTP_PORT: z.coerce.number().int().positive().default(587),
  /** z.coerce.boolean treats any non-empty string as true, so parse explicitly. */
  SMTP_SECURE: z
    .string()
    .optional()
    .transform((v) => v === 'true'),
  SMTP_USER: z.string().optional(),
  SMTP_PASS: z.string().optional(),
  SMTP_FROM: z.string().optional(),
  /** Public URL of the admin UI, used to build the sign-in link in invite emails. */
  APP_BASE_URL: z.string().url().optional(),
})

export type AppConfig = Readonly<z.infer<typeof configSchema>>

export function loadConfig(env: NodeJS.ProcessEnv = process.env): AppConfig {
  const result = configSchema.safeParse(env)
  if (!result.success) {
    const details = result.error.issues
      .map((issue) => `${issue.path.join('.')}: ${issue.message}`)
      .join('; ')
    throw new Error(`Invalid configuration: ${details}`)
  }

  /**
   * The fallback admin password is publicly documented, so a production
   * deployment must never run with it. Refuse to boot instead of seeding an
   * owner account anyone can log into.
   */
  if (
    result.data.NODE_ENV === 'production' &&
    result.data.DEFAULT_ADMIN_PASSWORD === 'flagraft-admin'
  ) {
    throw new Error(
      'Invalid configuration: DEFAULT_ADMIN_PASSWORD must be set to a strong, non-default value in production',
    )
  }

  return Object.freeze(result.data)
}