/**
 * Cron handlers for scheduled platform jobs.
 *
 * The endpoints in `api/handlers.ts` validate `Authorization: Bearer ${CRON_SECRET}`
 * before invoking these — Vercel Cron sends that header automatically when
 * `CRON_SECRET` is defined in the project's environment. See:
 * https://vercel.com/docs/cron-jobs/manage-cron-jobs#securing-cron-jobs
 *
 * Each handler is **idempotent** and **bounded** — safe to invoke from any
 * scheduler (Vercel Cron, GitHub Actions, EventBridge, k8s CronJob, etc.) and
 * safe to invoke twice in the same window.
 */
import { createHmac, randomBytes, timingSafeEqual } from 'node:crypto';
import { schedulingCronHandler } from '../scheduling/index.js';
import { getActuateConfig } from '../config/runtime.js';
import { applySeoOverrides, getSeoOverrides } from '../seo/config-store.js';
import { resolveEffectiveMeta } from '../seo/page-meta.js';
const DAY = 24 * 60 * 60 * 1000;
const DEFAULT_CLEANUP = {
    sessionRetentionMs: 7 * DAY,
    auditLogRetentionMs: 90 * DAY,
    trashRetentionMs: 30 * DAY,
    passwordResetRetentionMs: 1 * DAY,
    formSubmissionRetentionMs: 0,
    versionRetentionMs: 90 * DAY,
    webhookDeliveryRetentionMs: 30 * DAY,
    apiRequestMetricRetentionMs: 30 * DAY,
};
function modelExists(db, name) {
    if (db == null || typeof db !== 'object' || !(name in db))
        return false;
    // Deliberate dynamic access: this guard exists to probe consumer schemas
    // that may not include every model cms-core knows about.
    const delegate = db[name];
    // `typeof null === 'object'` in JavaScript, so we have to check `!== null`
    // explicitly. Without this guard, `{ session: null }` would pass the check
    // and `db.session.deleteMany(...)` would throw a TypeError that the outer
    // try/catch only papers over.
    return delegate !== null && typeof delegate === 'object';
}
/**
 * Delete stale rows from session, audit log, trash, and password-reset tables.
 *
 * Each deletion is wrapped in its own try/catch so a missing model (e.g. an
 * older Prisma schema without `passwordResetToken`) doesn't fail the entire
 * job — partial cleanup is still useful and the caller logs the count.
 */
export async function processCleanup(db, options = {}) {
    const opts = { ...DEFAULT_CLEANUP, ...options };
    const now = Date.now();
    const result = {
        sessionsDeleted: 0,
        auditLogsDeleted: 0,
        documentsDeleted: 0,
        passwordResetTokensDeleted: 0,
        formSubmissionsDeleted: 0,
        versionsDeleted: 0,
        webhookDeliveriesDeleted: 0,
        apiRequestMetricsDeleted: 0,
    };
    if (modelExists(db, 'session')) {
        try {
            const cutoff = new Date(now - opts.sessionRetentionMs);
            const r = await db.session.deleteMany({
                where: {
                    OR: [{ revokedAt: { lt: cutoff } }, { expiresAt: { lt: cutoff } }],
                },
            });
            result.sessionsDeleted = r?.count ?? 0;
        }
        catch (err) {
            console.warn('[actuate][cron] session cleanup failed:', errMsg(err));
        }
    }
    if (modelExists(db, 'auditLog')) {
        try {
            const cutoff = new Date(now - opts.auditLogRetentionMs);
            const r = await db.auditLog.deleteMany({ where: { timestamp: { lt: cutoff } } });
            result.auditLogsDeleted = r?.count ?? 0;
        }
        catch (err) {
            console.warn('[actuate][cron] audit log cleanup failed:', errMsg(err));
        }
    }
    if (modelExists(db, 'document')) {
        try {
            const cutoff = new Date(now - opts.trashRetentionMs);
            const r = await db.document.deleteMany({ where: { deletedAt: { lt: cutoff } } });
            result.documentsDeleted = r?.count ?? 0;
        }
        catch (err) {
            console.warn('[actuate][cron] trash cleanup failed:', errMsg(err));
        }
    }
    if (modelExists(db, 'passwordResetToken')) {
        try {
            const cutoff = new Date(now - opts.passwordResetRetentionMs);
            const r = await db.passwordResetToken.deleteMany({
                where: {
                    OR: [{ usedAt: { lt: cutoff } }, { expiresAt: { lt: cutoff } }],
                },
            });
            result.passwordResetTokensDeleted = r?.count ?? 0;
        }
        catch (err) {
            console.warn('[actuate][cron] password reset token cleanup failed:', errMsg(err));
        }
    }
    // PII retention: hard-delete old form submissions when configured. Cascades
    // to `FormSubmissionFile` rows (onDelete: Cascade). Disabled when the
    // retention window is 0 so a misconfigured/default install never purges data.
    if (opts.formSubmissionRetentionMs > 0 && modelExists(db, 'formSubmission')) {
        try {
            const cutoff = new Date(now - opts.formSubmissionRetentionMs);
            const r = await db.formSubmission.deleteMany({ where: { submittedAt: { lt: cutoff } } });
            result.formSubmissionsDeleted = r?.count ?? 0;
        }
        catch (err) {
            console.warn('[actuate][cron] form submission retention failed:', errMsg(err));
        }
    }
    if (opts.versionRetentionMs > 0 && modelExists(db, 'version')) {
        try {
            const cutoff = new Date(now - opts.versionRetentionMs);
            const r = await db.version.deleteMany({ where: { createdAt: { lt: cutoff } } });
            result.versionsDeleted = r?.count ?? 0;
        }
        catch (err) {
            console.warn('[actuate][cron] version retention failed:', errMsg(err));
        }
    }
    if (opts.webhookDeliveryRetentionMs > 0 && modelExists(db, 'webhookDeliveryLog')) {
        try {
            const cutoff = new Date(now - opts.webhookDeliveryRetentionMs);
            const r = await db.webhookDeliveryLog.deleteMany({ where: { createdAt: { lt: cutoff } } });
            result.webhookDeliveriesDeleted = r?.count ?? 0;
        }
        catch (err) {
            console.warn('[actuate][cron] webhook delivery log retention failed:', errMsg(err));
        }
    }
    if (opts.apiRequestMetricRetentionMs > 0 && modelExists(db, 'apiRequestMetric')) {
        try {
            const cutoff = new Date(now - opts.apiRequestMetricRetentionMs);
            const r = await db.apiRequestMetric.deleteMany({ where: { bucketStart: { lt: cutoff } } });
            result.apiRequestMetricsDeleted = r?.count ?? 0;
        }
        catch (err) {
            console.warn('[actuate][cron] API request metric retention failed:', errMsg(err));
        }
    }
    return result;
}
/**
 * Run the same SEO checks as `POST /seo/scan`, but headlessly so a cron
 * (or a CLI / external scheduler) can invoke it without an admin session.
 *
 * Bounded by `maxDocuments` to avoid OOM / timeout on large catalogs —
 * defaults to 5000, which is well above the typical Vercel function memory
 * envelope while staying safely under the 60s cron execution limit.
 */
export async function processSeoScan(db, options = {}) {
    const maxDocuments = options.maxDocuments ?? 5000;
    if (!modelExists(db, 'document')) {
        return { total: 0, pagesWithIssues: 0, totalProblems: 0, issues: [] };
    }
    // Scope to front-end content collections when the caller provides them, so the
    // scan never flags structural (navigation/footer) or benchmark documents.
    const where = { status: 'PUBLISHED', deletedAt: null };
    if (options.allowedCollections) {
        if (options.allowedCollections.length === 0) {
            return { total: 0, pagesWithIssues: 0, totalProblems: 0, issues: [] };
        }
        where.collection = { in: options.allowedCollections };
    }
    const documents = await db.document.findMany({
        where,
        select: {
            id: true,
            title: true,
            slug: true,
            collection: true,
            data: true,
            plainText: true,
            publishedAt: true,
        },
        take: maxDocuments,
    });
    const seoOverrides = await getSeoOverrides(db).catch(() => null);
    const mergedConfig = applySeoOverrides(getActuateConfig(), seoOverrides);
    const siteSeo = mergedConfig?.seo;
    const issues = [];
    for (const doc of documents) {
        const data = (doc.data ?? {});
        const problems = [];
        const collectionDef = mergedConfig?.collections?.[doc.collection];
        const effective = resolveEffectiveMeta({
            data,
            docTitle: doc.title,
            publishedAt: doc.publishedAt,
            collection: collectionDef,
            collectionSlug: doc.collection,
            site: siteSeo,
            siteUrl: siteSeo?.siteUrl,
        });
        if (!effective.metaTitle)
            problems.push('Missing meta title');
        if (!effective.metaDescription)
            problems.push('Missing meta description');
        const plainText = (doc.plainText ?? '');
        if (plainText.length > 0 && plainText.length < 300) {
            problems.push('Content is too short (< 300 characters)');
        }
        const content = typeof data.body === 'string'
            ? data.body
            : typeof data.content === 'string'
                ? data.content
                : '';
        if (content) {
            const imgMatches = content.match(/<img\b[^>]*>/gi) ?? [];
            // Case-insensitive: `<IMG ALT="text">` is valid HTML and should not be
            // flagged as missing. Tests the literal attribute name with `\balt\s*=`
            // to avoid matching unrelated tokens that happen to contain `alt=`.
            const missingAlt = imgMatches.filter((img) => !/\balt\s*=/i.test(img)).length;
            if (missingAlt > 0)
                problems.push(`${missingAlt} image(s) missing alt text`);
            // Case-insensitive to match the HTML spec — `<H1>`, `<h1>`, `<h1 class="...">`
            // and `<h1\n>` all count.
            if (!/<h1\b/i.test(content)) {
                problems.push('No H1 heading found in content');
            }
        }
        if (problems.length > 0) {
            issues.push({
                documentId: doc.id,
                title: doc.title ?? 'Untitled',
                slug: doc.slug ?? '',
                problems,
            });
        }
    }
    return {
        total: documents.length,
        pagesWithIssues: issues.length,
        totalProblems: issues.reduce((sum, i) => sum + i.problems.length, 0),
        issues,
    };
}
/** Re-export for convenience so the route handler can import everything from one module. */
export { schedulingCronHandler };
/**
 * Validate a Vercel-style cron Authorization header against `CRON_SECRET`.
 *
 * Returns `false` if the env var is missing — that's deliberately fail-closed
 * so a misconfigured deploy can't silently expose cron endpoints to the public
 * internet. Comparison is constant-time to defeat byte-by-byte timing attacks.
 */
export function isAuthorizedCronRequest(authHeader) {
    const secret = process.env.CRON_SECRET;
    if (!secret || secret.length === 0)
        return false;
    if (!authHeader)
        return false;
    // Vercel sends `Authorization: Bearer <CRON_SECRET>`. Some self-hosted
    // schedulers send the bare value; accept both.
    const trimmed = authHeader.startsWith('Bearer ') ? authHeader.slice(7) : authHeader;
    return constantTimeEqual(trimmed, secret);
}
/**
 * Per-process random HMAC key. Using a per-process key (not a fixed string)
 * means an attacker can't precompute HMACs offline to compare against — the
 * comparison is between two HMACs they cannot directly observe.
 */
const HMAC_COMPARE_KEY = randomBytes(32);
/**
 * Compare two strings in constant time, including across length differences.
 *
 * The naive approach — early-return on length mismatch then per-character
 * XOR — leaks the secret length: an attacker can probe `Authorization`
 * header values of varying lengths and time the response to learn how many
 * bytes `CRON_SECRET` actually has, reducing the brute-force search space.
 *
 * Instead, HMAC-SHA256 both inputs (yielding fixed 32-byte digests
 * regardless of input length) and compare those with `timingSafeEqual`.
 * `timingSafeEqual` requires equal-length buffers, which the HMAC step
 * guarantees, and it's the only constant-time comparison primitive in
 * `node:crypto` that's safe across all V8 optimisations.
 */
function constantTimeEqual(a, b) {
    const hmacA = createHmac('sha256', HMAC_COMPARE_KEY).update(a).digest();
    const hmacB = createHmac('sha256', HMAC_COMPARE_KEY).update(b).digest();
    return timingSafeEqual(hmacA, hmacB);
}
function errMsg(err) {
    return err instanceof Error ? err.message : String(err);
}
//# sourceMappingURL=index.js.map