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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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, }; }