Ctrl K
ComunidadGuías…

lib/env-validation.ts

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

Archivo original ↗Preguntar a KiBot
type EnvCheck = {
  name: string;
  configured: boolean;
  required: boolean;
  status: "ready" | "missing" | "invalid";
  note?: string;
};

const edgeProviders = new Set([
  "cloudflare",
  "vercel",
  "cloudflare_vercel",
  "aws_waf",
  "other",
]);

const rateLimitModes = new Set(["in_memory", "redis", "distributed"]);

function credentialKeyStatus(): Pick<EnvCheck, "status" | "note"> {
  const raw = process.env.CREDENTIAL_ENCRYPTION_KEY;
  if (!raw) return { status: "missing", note: "Required for encrypted provider credential storage." };

  try {
    const key = raw.startsWith("base64:")
      ? Buffer.from(raw.slice("base64:".length), "base64")
      : Buffer.from(raw, "hex");
    if (key.length !== 32) {
      return { status: "invalid", note: "Must decode to exactly 32 bytes." };
    }
  } catch {
    return { status: "invalid", note: "Must be hex or base64: encoded bytes." };
  }

  return { status: "ready" };
}

function optionalIsoDateStatus(name: string): Pick<EnvCheck, "configured" | "status" | "note"> {
  const raw = process.env[name]?.trim();
  if (!raw) return { configured: false, status: "ready" };

  const parsed = new Date(raw);
  if (Number.isNaN(parsed.getTime())) {
    return { configured: true, status: "invalid", note: "Must be an ISO timestamp, for example 2026-06-13T17:30:00.000Z." };
  }

  return { configured: true, status: "ready", note: parsed.toISOString() };
}

function hasAnyEnv(names: string[]) {
  return names.some((name) => Boolean(process.env[name]?.trim()));
}

function requiredHttpUrlStatus(names: string[]): Pick<EnvCheck, "configured" | "status" | "note"> {
  if (!hasAnyEnv(names)) return { configured: false, status: "missing" };

  for (const name of names) {
    const raw = process.env[name]?.trim();
    if (!raw) continue;

    try {
      const url = new URL(raw);
      if (url.protocol === "https:" || url.protocol === "http:") {
        return { configured: true, status: "ready", note: name };
      }
    } catch {
      return { configured: true, status: "invalid", note: `${name} must be an http(s) Redis REST URL.` };
    }

    return { configured: true, status: "invalid", note: `${name} must be an http(s) Redis REST URL.` };
  }

  return { configured: false, status: "missing" };
}

function optionalPositiveIntegerStatus(name: string): Pick<EnvCheck, "configured" | "status" | "note"> {
  const raw = process.env[name]?.trim();
  if (!raw) return { configured: false, status: "ready", note: "default" };

  const value = Number(raw);
  if (!Number.isInteger(value) || value <= 0) {
    return { configured: true, status: "invalid", note: "Must be a positive integer." };
  }

  return { configured: true, status: "ready", note: raw };
}

function requiredBooleanStatus(name: string, readyNote: string): Pick<EnvCheck, "configured" | "status" | "note"> {
  const raw = process.env[name]?.trim();
  if (!raw) return { configured: false, status: "missing", note: "Manual deployment attestation required." };
  if (raw !== "true") return { configured: true, status: "invalid", note: "Must be true for production readiness." };
  return { configured: true, status: "ready", note: readyNote };
}

function optionalBooleanStatus(name: string): Pick<EnvCheck, "configured" | "status" | "note"> {
  const raw = process.env[name]?.trim();
  if (!raw) return { configured: false, status: "ready", note: "default" };
  if (raw !== "true" && raw !== "false") return { configured: true, status: "invalid", note: "Must be true or false." };
  return { configured: true, status: "ready", note: raw };
}

function edgeProviderStatus(): Pick<EnvCheck, "configured" | "status" | "note"> {
  const raw = process.env.ASTRAIL_EDGE_PROVIDER?.trim();
  if (!raw) return { configured: false, status: "missing", note: "Set cloudflare, vercel, cloudflare_vercel, aws_waf, or other." };
  if (!edgeProviders.has(raw)) {
    return { configured: true, status: "invalid", note: "Allowed values: cloudflare, vercel, cloudflare_vercel, aws_waf, other." };
  }

  return { configured: true, status: "ready", note: raw };
}

function rateLimitModeStatus(): Pick<EnvCheck, "configured" | "status" | "note"> {
  const raw = process.env.RATE_LIMIT_MODE?.trim();
  if (!raw) return { configured: false, status: "missing", note: "Set in_memory for local, redis or distributed for production." };
  if (!rateLimitModes.has(raw)) {
    return { configured: true, status: "invalid", note: "Allowed values: in_memory, redis, distributed." };
  }

  return {
    configured: true,
    status: "ready",
    note: raw === "in_memory"
      ? "Local-only mode; production must also configure Redis REST envs."
      : raw,
  };
}

export function validateRuntimeEnv() {
  const credentialStatus = credentialKeyStatus();
  const billingResetStatus = optionalIsoDateStatus("ASTRAIL_BILLING_RESET_AT");
  const mcpWindowStatus = optionalPositiveIntegerStatus("ASTRAIL_MCP_EDGE_RATE_LIMIT_WINDOW_MS");
  const mcpIpLimitStatus = optionalPositiveIntegerStatus("ASTRAIL_MCP_EDGE_RATE_LIMIT_MAX");
  const mcpGlobalIpLimitStatus = optionalPositiveIntegerStatus("ASTRAIL_MCP_EDGE_GLOBAL_RATE_LIMIT_MAX");
  const mcpBearerLimitStatus = optionalPositiveIntegerStatus("ASTRAIL_MCP_EDGE_BEARER_RATE_LIMIT_MAX");
  const mcpGlobalBearerLimitStatus = optionalPositiveIntegerStatus("ASTRAIL_MCP_EDGE_GLOBAL_BEARER_RATE_LIMIT_MAX");
  const mcpMaxBodyStatus = optionalPositiveIntegerStatus("ASTRAIL_MCP_EDGE_MAX_BODY_BYTES");
  const runtimeLimitStatus = optionalPositiveIntegerStatus("ASTRAIL_RUNTIME_RATE_LIMIT_MAX");
  const runtimeWindowStatus = optionalPositiveIntegerStatus("ASTRAIL_RUNTIME_RATE_LIMIT_WINDOW_MS");
  const runtimeBucketsStatus = optionalPositiveIntegerStatus("ASTRAIL_RUNTIME_RATE_LIMIT_BUCKETS");
  const edgeProvider = edgeProviderStatus();
  const edgeDdos = requiredBooleanStatus("ASTRAIL_EDGE_DDOS_PROTECTION_CONFIRMED", "Provider volumetric protection confirmed.");
  const edgeWaf = requiredBooleanStatus("ASTRAIL_EDGE_WAF_CONFIRMED", "Provider WAF/firewall rules confirmed.");
  const edgeBot = requiredBooleanStatus("ASTRAIL_EDGE_BOT_PROTECTION_CONFIRMED", "Provider bot/challenge controls confirmed.");
  const edgeBody = requiredBooleanStatus("ASTRAIL_EDGE_BODY_SIZE_LIMIT_CONFIRMED", "Provider request body limits confirmed.");
  const turnstileRequired = optionalBooleanStatus("CLOUDFLARE_TURNSTILE_REQUIRED");
  const hasTurnstileSiteKey = hasAnyEnv(["NEXT_PUBLIC_CLOUDFLARE_TURNSTILE_SITE_KEY", "NEXT_PUBLIC_TURNSTILE_SITE_KEY"]);
  const hasTurnstileSecretKey = hasAnyEnv(["CLOUDFLARE_TURNSTILE_SECRET_KEY", "TURNSTILE_SECRET_KEY"]);
  const rateLimitMode = rateLimitModeStatus();
  const hasNeonAuthUrl = Boolean(process.env.NEXT_PUBLIC_NEON_AUTH_URL ?? process.env.NEON_AUTH_BASE_URL);
  const hasNeonDataApiUrl = Boolean(process.env.NEXT_PUBLIC_NEON_DATA_API_URL ?? process.env.NEON_DATA_API_URL);
  const rateLimitRedisUrlStatus = requiredHttpUrlStatus(["ASTRAIL_RATE_LIMIT_REDIS_REST_URL", "UPSTASH_REDIS_REST_URL"]);
  const hasRateLimitRedisToken = hasAnyEnv(["ASTRAIL_RATE_LIMIT_REDIS_REST_TOKEN", "UPSTASH_REDIS_REST_TOKEN"]);
  const redisMode = process.env.RATE_LIMIT_MODE === "redis" || process.env.RATE_LIMIT_MODE === "distributed";
  const checks: EnvCheck[] = [
    {
      name: "NEXT_PUBLIC_NEON_AUTH_URL or NEON_AUTH_BASE_URL",
      configured: hasNeonAuthUrl,
      required: true,
      status: hasNeonAuthUrl ? "ready" : "missing",
    },
    {
      name: "NEXT_PUBLIC_NEON_DATA_API_URL or NEON_DATA_API_URL",
      configured: hasNeonDataApiUrl,
      required: true,
      status: hasNeonDataApiUrl ? "ready" : "missing",
    },
    {
      name: "NEON_AUTH_SERVICE_EMAIL and NEON_AUTH_SERVICE_PASSWORD",
      configured: Boolean(process.env.NEON_AUTH_SERVICE_EMAIL && process.env.NEON_AUTH_SERVICE_PASSWORD),
      required: true,
      status: process.env.NEON_AUTH_SERVICE_EMAIL && process.env.NEON_AUTH_SERVICE_PASSWORD ? "ready" : "missing",
      note: "Credentials must belong to a verified Neon Auth admin; run configure:neon:service-user and smoke:neon-auth before launch.",
    },
    {
      name: "ANTHROPIC_API_KEY",
      configured: Boolean(process.env.ANTHROPIC_API_KEY),
      required: false,
      status: "ready",
      note: process.env.ANTHROPIC_API_KEY
        ? "AI-assisted generation enabled; provider failures fall back to deterministic generation."
        : "Optional; deterministic MCP generation is enabled.",
    },
    {
      name: "CREDENTIAL_ENCRYPTION_KEY",
      configured: Boolean(process.env.CREDENTIAL_ENCRYPTION_KEY),
      required: true,
      status: credentialStatus.status,
      note: credentialStatus.note,
    },
    {
      name: "RATE_LIMIT_MODE",
      configured: rateLimitMode.configured,
      required: true,
      status: rateLimitMode.status,
      note: rateLimitMode.note,
    },
    {
      name: "ASTRAIL_RATE_LIMIT_REDIS_REST_URL or UPSTASH_REDIS_REST_URL",
      configured: rateLimitRedisUrlStatus.configured,
      required: true,
      status: rateLimitRedisUrlStatus.status,
      note: rateLimitRedisUrlStatus.note ?? "Required for distributed /api/mcp edge abuse limits.",
    },
    {
      name: "ASTRAIL_RATE_LIMIT_REDIS_REST_TOKEN or UPSTASH_REDIS_REST_TOKEN",
      configured: hasRateLimitRedisToken,
      required: true,
      status: hasRateLimitRedisToken ? "ready" : "missing",
      note: "Required for distributed /api/mcp edge abuse limits.",
    },
    {
      name: "RATE_LIMIT_MODE redis/distributed pairing",
      configured: redisMode,
      required: true,
      status: redisMode && (rateLimitRedisUrlStatus.status !== "ready" || !hasRateLimitRedisToken) ? "invalid" : "ready",
      note: redisMode
        ? "Redis REST URL and token must both be set."
        : "Redis REST envs still control whether middleware can distribute edge buckets.",
    },
    {
      name: "ASTRAIL_MCP_EDGE_RATE_LIMIT_DISABLED",
      configured: Boolean(process.env.ASTRAIL_MCP_EDGE_RATE_LIMIT_DISABLED),
      required: true,
      status: process.env.ASTRAIL_MCP_EDGE_RATE_LIMIT_DISABLED === "true" ? "invalid" : "ready",
      note: process.env.ASTRAIL_MCP_EDGE_RATE_LIMIT_DISABLED === "true"
        ? "Must not be disabled for production."
        : "enabled",
    },
    {
      name: "ASTRAIL_MCP_EDGE_RATE_LIMIT_WINDOW_MS",
      configured: mcpWindowStatus.configured,
      required: false,
      status: mcpWindowStatus.status,
      note: mcpWindowStatus.note,
    },
    {
      name: "ASTRAIL_MCP_EDGE_RATE_LIMIT_MAX",
      configured: mcpIpLimitStatus.configured,
      required: false,
      status: mcpIpLimitStatus.status,
      note: mcpIpLimitStatus.note,
    },
    {
      name: "ASTRAIL_MCP_EDGE_GLOBAL_RATE_LIMIT_MAX",
      configured: mcpGlobalIpLimitStatus.configured,
      required: false,
      status: mcpGlobalIpLimitStatus.status,
      note: mcpGlobalIpLimitStatus.note,
    },
    {
      name: "ASTRAIL_MCP_EDGE_BEARER_RATE_LIMIT_MAX",
      configured: mcpBearerLimitStatus.configured,
      required: false,
      status: mcpBearerLimitStatus.status,
      note: mcpBearerLimitStatus.note,
    },
    {
      name: "ASTRAIL_MCP_EDGE_GLOBAL_BEARER_RATE_LIMIT_MAX",
      configured: mcpGlobalBearerLimitStatus.configured,
      required: false,
      status: mcpGlobalBearerLimitStatus.status,
      note: mcpGlobalBearerLimitStatus.note,
    },
    {
      name: "ASTRAIL_MCP_EDGE_MAX_BODY_BYTES",
      configured: mcpMaxBodyStatus.configured,
      required: true,
      status: mcpMaxBodyStatus.status,
      note: `${mcpMaxBodyStatus.note}; mirror this cap in Cloudflare/Vercel WAF rules.`,
    },
    {
      name: "ASTRAIL_RUNTIME_RATE_LIMIT_MAX",
      configured: runtimeLimitStatus.configured,
      required: false,
      status: runtimeLimitStatus.status,
      note: runtimeLimitStatus.note,
    },
    {
      name: "ASTRAIL_RUNTIME_RATE_LIMIT_WINDOW_MS",
      configured: runtimeWindowStatus.configured,
      required: false,
      status: runtimeWindowStatus.status,
      note: runtimeWindowStatus.note,
    },
    {
      name: "ASTRAIL_RUNTIME_RATE_LIMIT_BUCKETS",
      configured: runtimeBucketsStatus.configured,
      required: false,
      status: runtimeBucketsStatus.status,
      note: runtimeBucketsStatus.note,
    },
    {
      name: "ASTRAIL_EDGE_PROVIDER",
      configured: edgeProvider.configured,
      required: true,
      status: edgeProvider.status,
      note: edgeProvider.note,
    },
    {
      name: "ASTRAIL_EDGE_DDOS_PROTECTION_CONFIRMED",
      configured: edgeDdos.configured,
      required: true,
      status: edgeDdos.status,
      note: edgeDdos.note,
    },
    {
      name: "ASTRAIL_EDGE_WAF_CONFIRMED",
      configured: edgeWaf.configured,
      required: true,
      status: edgeWaf.status,
      note: edgeWaf.note,
    },
    {
      name: "ASTRAIL_EDGE_BOT_PROTECTION_CONFIRMED",
      configured: edgeBot.configured,
      required: true,
      status: edgeBot.status,
      note: edgeBot.note,
    },
    {
      name: "ASTRAIL_EDGE_BODY_SIZE_LIMIT_CONFIRMED",
      configured: edgeBody.configured,
      required: true,
      status: edgeBody.status,
      note: edgeBody.note,
    },
    {
      name: "NEXT_PUBLIC_CLOUDFLARE_TURNSTILE_SITE_KEY or NEXT_PUBLIC_TURNSTILE_SITE_KEY",
      configured: hasTurnstileSiteKey,
      required: true,
      status: hasTurnstileSiteKey ? "ready" : "missing",
      note: "Required for dashboard MCP create/test challenge widgets.",
    },
    {
      name: "CLOUDFLARE_TURNSTILE_SECRET_KEY or TURNSTILE_SECRET_KEY",
      configured: hasTurnstileSecretKey,
      required: true,
      status: hasTurnstileSecretKey ? "ready" : "missing",
      note: "Required for server-side Turnstile siteverify before MCP creation/test actions.",
    },
    {
      name: "CLOUDFLARE_TURNSTILE_REQUIRED",
      configured: turnstileRequired.configured,
      required: false,
      status: turnstileRequired.status,
      note: turnstileRequired.note,
    },
    {
      name: "ASTRAIL_BILLING_RESET_AT",
      configured: billingResetStatus.configured,
      required: false,
      status: billingResetStatus.status,
      note: billingResetStatus.note ?? "unset",
    },
  ];

  const requiredReady = checks
    .filter((check) => check.required)
    .every((check) => check.status === "ready");

  return {
    status: requiredReady ? "ready" : "degraded",
    checks,
  };
}