/**
 * Environment shape validation for the Actuate CMS runtime.
 *
 * The `/health` and admin diagnostics endpoints surface this so operators
 * notice mis-configured secrets *before* a runtime call hits them. We never
 * return the values themselves — only the validation verdict and a redacted
 * preview where it helps debugging (e.g. "first 4 hex chars match").
 *
 * Why a dedicated module:
 *   - Centralises all "is this env var well-formed" rules so consumers don't
 *     re-invent length / hex / placeholder checks per call site.
 *   - Fails closed on shape errors (e.g. a 31-char CMS_SECRET, an
 *     `aes256-local-dev-key-...` placeholder still in production) which the
 *     prior `presence-only` check missed entirely (audit issue M6).
 */
const HEX_RE = /^[0-9a-fA-F]+$/;
const PLACEHOLDER_PATTERNS = [
    /change-?(me|in-?prod)/i,
    /your-?(secret|key|token)/i,
    /placeholder/i,
    /example/i,
    /todo/i,
    /aes256-local-dev-key/i,
];
function isPlaceholder(value) {
    return PLACEHOLDER_PATTERNS.some((p) => p.test(value));
}
/**
 * Determine which transactional email provider (if any) is configured via env.
 * Mirrors `resolveEmailConfigFromEnv()` in `@actuate-media/plugin-email`:
 * an explicit `EMAIL_PROVIDER` wins, otherwise we infer from the presence of
 * `SMTP_HOST` (SMTP), an SMTP.com API key (SMTP.com), or `RESEND_API_KEY`
 * (Resend). Returns a human-readable provider label, or `null` when none is
 * configured.
 */
function detectEmailProvider(source) {
    const provider = source.get('EMAIL_PROVIDER');
    const resend = source.get('RESEND_API_KEY');
    const smtpHost = source.get('SMTP_HOST');
    const sesRegion = source.get('SES_REGION') ?? source.get('AWS_REGION');
    // Accept the canonical `SMTPCOM_API_KEY` and the shorter `SMTP_KEY` alias
    // (SMTP.com's dashboard surfaces the latter). Kept in lockstep with
    // `resolveEmailConfigFromEnv()` in `@actuate-media/plugin-email`.
    const smtpComKey = source.get('SMTPCOM_API_KEY') ?? source.get('SMTP_KEY');
    switch (provider) {
        case 'smtp':
            return smtpHost ? 'SMTP' : null;
        case 'ses':
            return sesRegion ? 'AWS SES' : null;
        case 'smtpcom':
            return smtpComKey ? 'SMTP.com' : null;
        case 'resend':
            return resend ? 'Resend' : null;
        case undefined:
        case '':
            // No explicit provider: infer from whichever credentials are present.
            return smtpHost ? 'SMTP' : smtpComKey ? 'SMTP.com' : resend ? 'Resend' : null;
        default:
            return null;
    }
}
const PROCESS_ENV = {
    get: (name) => process.env[name],
};
/**
 * Validate the shape of every well-known runtime env var. Pass a custom
 * `source` to test without mutating `process.env`.
 */
export function validateEnvShape(source = PROCESS_ENV) {
    const checks = [];
    // ─── CMS_SECRET ──────────────────────────────────────────────
    // The `source.get('CMS_SECRET')` lookup is intentionally pluggable: the
    // runtime caller (`/api/cms/health`) wraps this so the lookup also covers
    // the legacy `CMS_SESSION_SECRET` env var and `actuate.config.ts → secret`
    // — those are equally valid sources at runtime via `getSessionSecret()`.
    // Without that wrapping, a config-file-only deploy would be falsely
    // reported as `missing` (Bugbot review on PR #43, Medium).
    const secret = source.get('CMS_SECRET');
    if (!secret) {
        checks.push({
            name: 'CMS_SECRET',
            status: 'missing',
            message: 'Required. Generate with `node -e "console.log(crypto.randomBytes(32).toString(\'hex\'))"` and set `CMS_SECRET`, or pass `secret` in `actuate.config.ts`.',
            required: true,
        });
    }
    else if (secret.length < 32) {
        checks.push({
            name: 'CMS_SECRET',
            status: 'error',
            message: `Must be ≥ 32 characters (got ${secret.length}). JWT signing requires this minimum.`,
            required: true,
        });
    }
    else if (isPlaceholder(secret)) {
        checks.push({
            name: 'CMS_SECRET',
            status: 'error',
            message: 'Looks like a placeholder string. Replace with a real 32+ char random secret.',
            required: true,
        });
    }
    else {
        // Intentionally do not include `secret.length` here — `/health` is
        // unauthenticated and exact secret lengths narrow brute-force search
        // space for an attacker. The error/warn branches above can stay verbose
        // because they only trigger on operator-visible misconfigurations.
        checks.push({
            name: 'CMS_SECRET',
            status: 'ok',
            message: 'Configured.',
            required: true,
        });
    }
    // ─── CMS_ENCRYPTION_KEY ──────────────────────────────────────
    const encKey = source.get('CMS_ENCRYPTION_KEY');
    if (!encKey) {
        checks.push({
            name: 'CMS_ENCRYPTION_KEY',
            status: 'warn',
            message: 'Optional but recommended. AES-256-GCM field encryption will throw at runtime when not set. Provide 64 hex chars.',
            required: false,
        });
    }
    else if (encKey.length !== 64) {
        checks.push({
            name: 'CMS_ENCRYPTION_KEY',
            status: 'error',
            message: `Must be exactly 64 hex characters (32 bytes). Got ${encKey.length}.`,
            required: false,
        });
    }
    else if (!HEX_RE.test(encKey)) {
        // No `isPlaceholder()` branch follows — by this point the value has
        // already passed length-64 and `HEX_RE`, meaning it's pure hex chars.
        // Every pattern in `PLACEHOLDER_PATTERNS` requires non-hex letters
        // (`g`, `h`, `l`, `m`, `o`, `p`, `r`, `s`, `t`, `u`, `v`, `w`, `x`, `y`, `z`,
        // hyphens) so a passing hex string can never match a placeholder. The
        // message below already says "Looks like a placeholder" for the common
        // case where the configured value is a documented dev placeholder
        // (e.g. `aes256-local-dev-key-change-in-prod`, which fails this branch).
        checks.push({
            name: 'CMS_ENCRYPTION_KEY',
            status: 'error',
            message: 'Must contain only hex characters (0-9, a-f). Looks like a placeholder.',
            required: false,
        });
    }
    else {
        checks.push({
            name: 'CMS_ENCRYPTION_KEY',
            status: 'ok',
            message: 'Configured.',
            required: false,
        });
    }
    // ─── DATABASE_URL ────────────────────────────────────────────
    const dbUrl = source.get('DATABASE_URL');
    if (!dbUrl) {
        checks.push({
            name: 'DATABASE_URL',
            status: 'missing',
            message: 'Required. Connection string for the Prisma client.',
            required: true,
        });
    }
    else if (!/^(postgres(ql)?|mysql|file|sqlite|prisma(\+postgres)?):/i.test(dbUrl)) {
        checks.push({
            name: 'DATABASE_URL',
            status: 'error',
            // `prisma://` and `prisma+postgres://` are Prisma Accelerate / Prisma
            // Postgres URLs; the runtime routes those through the Accelerate
            // transport (see apps/dev/lib/prisma.ts) rather than a TCP driver.
            message: 'Must start with a known DB scheme (postgres://, mysql://, file:, sqlite://, prisma://, prisma+postgres://).',
            required: true,
        });
    }
    else {
        checks.push({
            name: 'DATABASE_URL',
            status: 'ok',
            message: 'Configured.',
            required: true,
        });
    }
    // ─── BLOB_READ_WRITE_TOKEN ──────────────────────────────────
    const blob = source.get('BLOB_READ_WRITE_TOKEN');
    // A connected Blob store provisions BLOB_STORE_ID (and a webhook public key).
    // When those exist but the read-write token does not, the store is "linked"
    // yet uploads will fail — a hard error, not a soft warning (the partial-link
    // failure mode seen in deployment).
    const blobStoreLinked = Boolean(source.get('BLOB_STORE_ID') ?? source.get('BLOB_WEBHOOK_PUBLIC_KEY'));
    if (!blob) {
        checks.push({
            name: 'BLOB_READ_WRITE_TOKEN',
            status: blobStoreLinked ? 'error' : 'warn',
            message: blobStoreLinked
                ? 'A Vercel Blob store is linked (BLOB_STORE_ID present) but BLOB_READ_WRITE_TOKEN is missing. Media uploads will fail until the token is provisioned to this environment.'
                : 'Required for media uploads on Vercel Blob storage. Without it, /media/upload will fail.',
            required: blobStoreLinked,
        });
    }
    else if (!blob.startsWith('vercel_blob_')) {
        checks.push({
            name: 'BLOB_READ_WRITE_TOKEN',
            status: 'warn',
            message: 'Does not start with `vercel_blob_` — verify this is a real Vercel Blob token.',
            required: false,
        });
    }
    else {
        checks.push({
            name: 'BLOB_READ_WRITE_TOKEN',
            status: 'ok',
            message: 'Configured.',
            required: false,
        });
    }
    // ─── UPSTASH_REDIS_REST_URL / TOKEN ─────────────────────────
    // Accept the Vercel Marketplace Upstash (Redis/KV) names as a fallback —
    // that integration provisions KV_REST_API_URL / KV_REST_API_TOKEN, which
    // are the same REST credentials. Mirrors `resolveUpstashCredentials()` in
    // `security/rate-limit.ts`; without this, the recommended provisioning path
    // would still warn (and silently use the in-memory limiter).
    const upstashUrl = source.get('UPSTASH_REDIS_REST_URL') ?? source.get('KV_REST_API_URL');
    const upstashToken = source.get('UPSTASH_REDIS_REST_TOKEN') ?? source.get('KV_REST_API_TOKEN');
    if (!upstashUrl && !upstashToken) {
        checks.push({
            name: 'UPSTASH_REDIS_REST_URL',
            status: 'warn',
            message: 'Optional. Without Upstash, rate limiting falls back to in-memory (single-process only — unsafe across serverless invocations).',
            required: false,
        });
    }
    else if (upstashUrl && !upstashToken) {
        checks.push({
            name: 'UPSTASH_REDIS_REST_TOKEN',
            status: 'error',
            message: 'URL is set but token is missing — rate limiter will refuse to start.',
            required: false,
        });
    }
    else if (!upstashUrl && upstashToken) {
        checks.push({
            name: 'UPSTASH_REDIS_REST_URL',
            status: 'error',
            message: 'Token is set but URL is missing — rate limiter will refuse to start.',
            required: false,
        });
    }
    else {
        try {
            // eslint-disable-next-line no-new
            new URL(upstashUrl);
            checks.push({
                name: 'UPSTASH_REDIS_REST_URL',
                status: 'ok',
                message: 'Configured (Upstash backend active).',
                required: false,
            });
        }
        catch {
            checks.push({
                name: 'UPSTASH_REDIS_REST_URL',
                status: 'error',
                message: 'Not a valid URL.',
                required: false,
            });
        }
    }
    // ─── Email provider (RESEND_API_KEY / SMTP / SES / SMTP.com) ──
    // Transactional email is optional. We surface it under the historical
    // `RESEND_API_KEY` check name (stable /health shape) but treat ANY supported
    // provider as "configured" so SMTP/SES/SMTP.com users aren't warned about a
    // missing Resend key.
    const resend = source.get('RESEND_API_KEY');
    const activeEmailProvider = detectEmailProvider(source);
    if (!activeEmailProvider) {
        checks.push({
            name: 'RESEND_API_KEY',
            status: 'warn',
            message: 'Optional. No email provider configured (RESEND_API_KEY for Resend; EMAIL_PROVIDER=smtp with SMTP_HOST/SMTP_USER/SMTP_PASS for a generic relay; or SMTP_KEY/SMTP_CHANNEL/SMTP_EMAIL for SMTP.com). Transactional email (lead notifications, password reset) is silently skipped.',
            required: false,
        });
    }
    else if (activeEmailProvider === 'Resend' && resend && !resend.startsWith('re_')) {
        checks.push({
            name: 'RESEND_API_KEY',
            status: 'warn',
            message: 'Does not start with `re_` — verify this is a real Resend API key.',
            required: false,
        });
    }
    else {
        checks.push({
            name: 'RESEND_API_KEY',
            status: 'ok',
            message: `Configured (${activeEmailProvider}).`,
            required: false,
        });
    }
    // ─── CRON_SECRET ────────────────────────────────────────────
    const cronSecret = source.get('CRON_SECRET');
    if (!cronSecret) {
        checks.push({
            name: 'CRON_SECRET',
            status: 'warn',
            message: 'Required to authorise /api/cms/cron/* endpoints. Without it, cron routes return 401 to all callers.',
            required: false,
        });
    }
    else if (cronSecret.length < 16) {
        checks.push({
            name: 'CRON_SECRET',
            status: 'error',
            message: `Should be ≥ 16 chars (got ${cronSecret.length}). Cron endpoints are publicly addressable.`,
            required: false,
        });
    }
    else if (isPlaceholder(cronSecret)) {
        checks.push({
            name: 'CRON_SECRET',
            status: 'error',
            message: 'Looks like a placeholder string.',
            required: false,
        });
    }
    else {
        // Same reasoning as CMS_SECRET above — keep the length out of the
        // unauthenticated /health surface.
        checks.push({
            name: 'CRON_SECRET',
            status: 'ok',
            message: 'Configured.',
            required: false,
        });
    }
    const errorCount = checks.filter((c) => c.status === 'error' || (c.status === 'missing' && c.required)).length;
    const warnCount = checks.filter((c) => c.status === 'warn').length;
    return {
        ok: errorCount === 0,
        errorCount,
        warnCount,
        checks,
    };
}
//# sourceMappingURL=env.js.map