import { assertCollectionAccess, buildRelationLookup } from '../actions.js';
import { populateRelations, parsePopulateParam } from '../fields/relations.js';
import { verifySession } from '../auth/session.js';
import { resolveEmailSender } from '../email/sender.js';
import { workspaceDomainFromUrl } from '../auth/team.js';
import { getDB } from '../db.js';
import { formatBytes } from '../media/optimize.js';
import { publicReadCacheControl } from '../cache/http.js';
import { verifyCaptcha, getCaptchaConfig } from '../security/captcha.js';
import { FormNotFoundError, FormValidationError, FormConflictError, FormHasSubmissionsError, } from '../forms/service.js';
import { processSubmission, SubmissionValidationError, } from '../forms/submission.js';
import { validateSubmissionFiles, FORM_UPLOAD_ALLOWED_MIME_TYPES, DEFAULT_MAX_FILE_BYTES, } from '../forms/files.js';
import { resolveSubmissionFields } from '../forms/submission.js';
import { FormWebhookError } from '../forms/webhooks.js';
import { createRateLimiter } from '../security/rate-limit.js';
import { getClientIp, isResolvedIp } from '../security/client-ip.js';
import { safeFetch } from '../security/safe-fetch.js';
import { enforceSessionLimits } from '../security/session-limits.js';
import { verifyReauth } from '../security/reauth.js';
import { getActuateConfig } from '../config/runtime.js';
import { hashApiKey, looksLikeApiKey, validateApiKeyScope, validateApiKeyGlobalScope, validateApiKeyMediaScope, validateApiKeyPageBuilderScope, validateApiKeyIp, } from '../security/api-key-enhanced.js';
import { guardedDb, ModelNotAvailableError } from './guarded-db.js';
// Opaque dynamic import so Turbopack/webpack won't statically analyze the specifier.
// Returns { put, del, ... } from @vercel/blob when available.
export async function importBlobStorage() {
    const mod = '@vercel/' + 'blob';
    return import(/* webpackIgnore: true */ mod);
}
export async function importAIPlugin() {
    const mod = '@actuate-media/' + 'plugin-ai';
    return import(/* webpackIgnore: true */ mod);
}
/**
 * Build the system/user prompt for an inline co-author action. Kept
 * deterministic and small so the governed runtime can route it to any
 * configured provider/model.
 */
export function buildCoauthorPrompt(action, body) {
    const text = body.text ?? '';
    switch (action) {
        case 'expand': {
            const hint = body.instructions ? ` Follow this guidance: ${body.instructions}.` : '';
            return {
                system: 'You are an expert writer. Expand the user\u2019s text with more detail and depth ' +
                    'while preserving its meaning and voice.' +
                    hint +
                    ' Return only the expanded text, with no preamble.',
                user: text,
                maxTokens: 2048,
            };
        }
        case 'compress': {
            const hint = typeof body.targetLength === 'number' && body.targetLength > 0
                ? ` Aim for about ${body.targetLength} words.`
                : '';
            return {
                system: 'You are an expert editor. Tighten the user\u2019s text, removing redundancy while ' +
                    'preserving the key information and voice.' +
                    hint +
                    ' Return only the edited text, with no preamble.',
                user: text,
                maxTokens: 1024,
            };
        }
        case 'proofread':
            return {
                system: 'You are a meticulous proofreader. Correct grammar, spelling, and punctuation in the ' +
                    'user\u2019s text. Preserve its meaning and voice. Return only the corrected text, ' +
                    'with no preamble.',
                user: text,
                maxTokens: 2048,
            };
        case 'rewrite':
        default: {
            const style = body.style ? ` Write in a ${body.style} style.` : '';
            const tone = body.tone ? ` Use a ${body.tone} tone.` : '';
            return {
                system: 'You are an expert editor. Rewrite the user\u2019s text to improve clarity and flow ' +
                    'while preserving its meaning.' +
                    style +
                    tone +
                    ' Return only the rewritten text, with no preamble.',
                user: text,
                maxTokens: 2048,
            };
        }
    }
}
/**
 * Common helper for the read endpoints. Reads the `?populate=` query param,
 * fetches the target collection's field schema, and expands every relation
 * reference into a {@link RelationSummary}. When the caller didn't ask for
 * population (or the doc has no `data` field) we short-circuit and return
 * the doc untouched.
 *
 * Lives here (not in `actions.ts`) because the rest of the actions API
 * stays purely DB-shaped — population is an HTTP concern.
 */
export async function maybePopulate(request, collection, doc, dbInstance) {
    if (!doc)
        return doc;
    const url = new URL(request.url);
    const opts = parsePopulateParam(url.searchParams.get('populate'));
    if (!opts)
        return doc;
    const cfg = getActuateConfig();
    const fields = cfg?.collections?.[collection]?.fields;
    if (!fields)
        return doc;
    const data = doc.data && typeof doc.data === 'object' ? doc.data : null;
    if (!data)
        return doc;
    try {
        const populated = await populateRelations(fields, data, buildRelationLookup(dbInstance), opts);
        return { ...doc, data: populated };
    }
    catch (err) {
        // Never let a populate failure mask the underlying document — log and
        // return the unpopulated doc.
        console.error('[actuate][api] populate failed for', collection, err);
        return doc;
    }
}
export const SECURITY_HEADERS = {
    'Content-Type': 'application/json',
    'X-Content-Type-Options': 'nosniff',
    'X-Frame-Options': 'DENY',
    'Referrer-Policy': 'strict-origin-when-cross-origin',
};
export function json(data, status = 200) {
    return new Response(JSON.stringify(data), {
        status,
        headers: { ...SECURITY_HEADERS },
    });
}
/**
 * Variant of `json()` that emits the Phase-5.5 public-read
 * `Cache-Control` header when the active CMS config opts in.
 *
 * - Session-cookie requests always get `private, no-store`.
 * - API-key / unauthenticated requests get
 *   `public, s-maxage=N, stale-while-revalidate=M` when
 *   `cache.publicReads.enabled` is true.
 * - Otherwise no `Cache-Control` header is set (i.e. the response is
 *   uncacheable downstream, matching the pre-5.5 behaviour).
 *
 * Use this for the public read endpoints (`/collections/:slug`,
 * `/collections/:slug/:id`, `/globals/:slug`, `/resolve`). Mutating
 * endpoints stay on the plain `json()` so the CDN never caches a write
 * response by accident.
 */
export function jsonWithCache(data, request, status = 200) {
    const cacheControl = publicReadCacheControl(request, getActuateConfig()?.cache);
    const headers = { ...SECURITY_HEADERS };
    if (cacheControl)
        headers['Cache-Control'] = cacheControl;
    return new Response(JSON.stringify(data), { status, headers });
}
export function errorResponse(message, status) {
    return json({ error: message }, status);
}
export function internalError(err, context) {
    if (err instanceof ModelNotAvailableError) {
        return modelNotAvailable(err.model);
    }
    const msg = err instanceof Error ? err.message : String(err);
    console.error(`[actuate][api]${context ? ` ${context}:` : ''} ${msg}`);
    return errorResponse('Internal server error', 500);
}
/**
 * Map a thrown action-layer error to the right HTTP status. The data layer
 * throws plain `Error`s with stable, human-readable prefixes for client-fixable
 * problems (publish validation, access/permission, not-found); those are 4xx
 * and the message is safe to return verbatim. Anything else falls through to
 * {@link internalError} (500, generic message, logged).
 */
export function actionError(err, context) {
    if (err instanceof ModelNotAvailableError)
        return modelNotAvailable(err.model);
    const msg = err instanceof Error ? err.message : String(err);
    if (/^Cannot publish —/.test(msg))
        return errorResponse(msg, 422);
    if (/insufficient permissions/i.test(msg) || /^Access denied/i.test(msg)) {
        return errorResponse(msg, 403);
    }
    if (/not found/i.test(msg))
        return errorResponse(msg, 404);
    return internalError(err, context);
}
export function clampPageSize(raw, max = 100, fallback = 20) {
    const n = Number(raw) || fallback;
    return Math.min(Math.max(1, n), max);
}
export function mediaUrl(storageKey) {
    const value = String(storageKey ?? '');
    if (!value)
        return '';
    return value.startsWith('http://') || value.startsWith('https://') || value.startsWith('/')
        ? value
        : '';
}
export function normalizeMediaItem(media) {
    const width = typeof media.width === 'number' ? media.width : null;
    const height = typeof media.height === 'number' ? media.height : null;
    return {
        ...media,
        name: media.filename ?? media.name ?? '',
        type: media.mimeType ?? media.type ?? '',
        size: formatBytes(Number(media.fileSize ?? media.sizeBytes ?? 0)),
        sizeBytes: Number(media.fileSize ?? media.sizeBytes ?? 0),
        date: media.createdAt ?? media.updatedAt ?? null,
        url: mediaUrl(media.storageKey ?? media.url),
        dimensions: width && height ? `${width}x${height}` : undefined,
        format: typeof media.mimeType === 'string' ? media.mimeType.split('/')[1] : undefined,
        altTag: media.altText ?? media.altTag ?? '',
        title: media.title ?? '',
    };
}
/**
 * Build the admin editor route for a document that references a media asset.
 * Pages own the `/pages/*` namespace; post-type collections use
 * `/posts/:type/:id/edit`; everything else falls back to the generic
 * collection editor. Kept in sync with the route table in `AdminRoot.tsx`.
 */
export function mediaUsageEditPath(collection, id) {
    if (collection === 'pages')
        return `/pages/${id}/edit`;
    const def = getActuateConfig()?.collections?.[collection];
    if (def?.type === 'post')
        return `/posts/${collection}/${id}/edit`;
    return `/collections/${collection}/${id}`;
}
/**
 * Map the `MediaUsage` join rows (with their related document) into the
 * `{ page, path }[]` shape the admin drawer renders. Soft-deleted documents
 * are excluded so trashed pages don't keep an asset looking "in use".
 */
export function buildMediaUsedOn(mediaUsages) {
    if (!Array.isArray(mediaUsages))
        return [];
    const seen = new Set();
    const out = [];
    for (const usage of mediaUsages) {
        const doc = usage?.document;
        if (!doc || doc.deletedAt || seen.has(doc.id))
            continue;
        seen.add(doc.id);
        out.push({
            page: doc.title || '(untitled)',
            path: mediaUsageEditPath(doc.collection, doc.id),
        });
    }
    return out;
}
export const MEDIA_TITLE_STOPWORDS = new Set([
    'a',
    'an',
    'the',
    'of',
    'and',
    'or',
    'to',
    'in',
    'on',
    'for',
    'with',
    'at',
    'by',
    'from',
    'is',
    'are',
    'as',
]);
/**
 * Derive a concise, Title-Cased title from AI-generated alt text. The alt
 * text is already a model output describing the image, so the title stays
 * grounded in real content rather than the filename.
 */
export function deriveMediaTitleFromAlt(alt) {
    if (!alt)
        return '';
    const firstClause = (alt.split(/[.;:\n]/)[0] ?? '').trim();
    const titled = firstClause
        .split(/\s+/)
        .slice(0, 9)
        .map((word, i) => {
        const lower = word.toLowerCase();
        if (i > 0 && MEDIA_TITLE_STOPWORDS.has(lower))
            return lower;
        return word.charAt(0).toUpperCase() + word.slice(1);
    })
        .join(' ');
    return titled.slice(0, 80).replace(/[\s,]+$/, '');
}
export function asRecord(value) {
    return value && typeof value === 'object' && !Array.isArray(value)
        ? value
        : {};
}
export function normalizeSubmission(submission) {
    const data = asRecord(submission.data);
    const attribution = asRecord(submission.attribution);
    return {
        ...submission,
        name: String(data.name ?? submission.name ?? ''),
        email: String(data.email ?? submission.email ?? ''),
        phone: data.phone ?? submission.phone,
        message: String(data.message ?? submission.message ?? ''),
        status: submission.status ?? 'new',
        attribution: {
            source: String(attribution.source ?? '(direct)'),
            medium: String(attribution.medium ?? '(none)'),
            campaign: String(attribution.campaign ?? ''),
            term: String(attribution.term ?? ''),
            content: String(attribution.content ?? ''),
            landingPage: String(attribution.landingPage ?? ''),
            referrer: String(attribution.referrer ?? ''),
            deviceType: attribution.deviceType ?? 'Desktop',
            clickIds: asRecord(attribution.clickIds),
            capturedAt: attribution.capturedAt ?? submission.submittedAt ?? submission.createdAt ?? null,
        },
    };
}
/**
 * Map a forms-service domain error to an HTTP response. Returns `null` for
 * unrecognised errors so the caller can fall through to `internalError`.
 */
export function mapFormError(err) {
    if (err instanceof FormNotFoundError)
        return errorResponse(err.message, 404);
    if (err instanceof FormValidationError) {
        return json({ error: err.message, code: 'INVALID_SCHEMA', details: err.errors }, 400);
    }
    if (err instanceof FormConflictError)
        return errorResponse(err.message, 409);
    if (err instanceof FormHasSubmissionsError) {
        return json({ error: err.message, code: 'FORM_HAS_SUBMISSIONS', submissionCount: err.submissionCount }, 409);
    }
    if (err instanceof FormWebhookError)
        return errorResponse(err.message, err.status);
    return null;
}
export function parseFormsQuery(url) {
    const sp = url.searchParams;
    const status = sp.get('status');
    const notify = sp.get('notifyEnabled');
    return {
        search: sp.get('search') ?? undefined,
        status: status === 'active' || status === 'draft' || status === 'archived' ? status : undefined,
        notifyEnabled: notify == null ? undefined : notify === 'true',
        sortBy: sp.get('sortBy') ?? undefined,
        sortDirection: sp.get('sortDirection') === 'asc'
            ? 'asc'
            : sp.get('sortDirection') === 'desc'
                ? 'desc'
                : undefined,
        page: sp.get('page') ? Number(sp.get('page')) : undefined,
        pageSize: sp.get('pageSize') ? Number(sp.get('pageSize')) : undefined,
    };
}
export function parseEntriesQuery(url) {
    const sp = url.searchParams;
    const spam = sp.get('spamStatus');
    const boolParam = (key) => {
        const v = sp.get(key);
        return v == null ? undefined : v === 'true';
    };
    return {
        search: sp.get('search') ?? undefined,
        formId: sp.get('formId') ?? sp.get('form') ?? undefined,
        unread: boolParam('unread'),
        starred: boolParam('starred'),
        archived: boolParam('archived'),
        spamStatus: spam === 'clean' || spam === 'suspected' || spam === 'spam'
            ? spam
            : undefined,
        dateFrom: sp.get('dateFrom') ?? undefined,
        dateTo: sp.get('dateTo') ?? undefined,
        thisWeek: boolParam('thisWeek'),
        sortBy: sp.get('sortBy') ?? undefined,
        sortDirection: sp.get('sortDirection') === 'asc' ? 'asc' : undefined,
        page: sp.get('page') ? Number(sp.get('page')) : undefined,
        pageSize: sp.get('pageSize') ? Number(sp.get('pageSize')) : undefined,
    };
}
// ─── Public form submission helpers ─────────────────────────────────────────
/**
 * Build CORS headers for the public form endpoints. When the form has no
 * embed allowlist we reflect the requesting origin (open embedding). When an
 * allowlist is set we only echo the origin if it matches, so a disallowed site
 * cannot read the response. Browser-level CORS is complemented by a hard
 * server-side origin check in the submit handler.
 */
export function formCorsHeaders(request, allowedDomains) {
    const origin = request.headers.get('origin');
    const headers = {
        'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
        'Access-Control-Allow-Headers': 'Content-Type, X-Requested-With',
        'Access-Control-Max-Age': '600',
        Vary: 'Origin',
    };
    if (!allowedDomains || allowedDomains.length === 0) {
        headers['Access-Control-Allow-Origin'] = origin || '*';
    }
    else if (origin && isOriginAllowed(origin, allowedDomains)) {
        headers['Access-Control-Allow-Origin'] = origin;
    }
    return headers;
}
export function normalizeAllowedHost(domain) {
    return domain
        .trim()
        .toLowerCase()
        .replace(/^https?:\/\//, '')
        .replace(/\/.*$/, '')
        .replace(/^\*\./, '');
}
export function isOriginAllowed(origin, allowedDomains) {
    let host;
    try {
        host = new URL(origin).hostname.toLowerCase();
    }
    catch {
        return false;
    }
    return allowedDomains
        .map(normalizeAllowedHost)
        .some((a) => a !== '' && (host === a || host.endsWith(`.${a}`)));
}
export function jsonWithHeaders(data, status, extra) {
    return new Response(JSON.stringify(data), {
        status,
        headers: { ...SECURITY_HEADERS, ...extra },
    });
}
/**
 * Parse a public submission request into field values, files, and metadata.
 * Supports both `application/json` (no files) and `multipart/form-data`
 * (files + fields). Unknown/oversized bodies are rejected by the caller's
 * Content-Length gate before this runs.
 */
export async function parseSubmissionRequest(request) {
    const contentType = request.headers.get('content-type') ?? '';
    if (contentType.includes('multipart/form-data')) {
        const form = await request.formData();
        const values = {};
        const files = [];
        let attribution = null;
        let elapsedMs = null;
        let pageUrl = null;
        let captchaToken = '';
        for (const [key, value] of form.entries()) {
            if (typeof value === 'object' && value !== null && 'arrayBuffer' in value) {
                const file = value;
                if (file.size === 0 && !file.name)
                    continue;
                const buffer = Buffer.from(await file.arrayBuffer());
                files.push({
                    fieldKey: key,
                    fileName: file.name || 'file',
                    mimeType: file.type || 'application/octet-stream',
                    size: buffer.byteLength,
                    buffer,
                });
                continue;
            }
            const str = String(value);
            if (key === '__attribution') {
                attribution = safeJsonObject(str);
            }
            else if (key === '__elapsedMs') {
                const n = Number(str);
                elapsedMs = Number.isFinite(n) ? n : null;
            }
            else if (key === '__pageUrl') {
                pageUrl = str || null;
            }
            else if (key === '__captchaToken' || key === 'captchaToken') {
                captchaToken = str;
            }
            else if (Object.prototype.hasOwnProperty.call(values, key)) {
                // Repeated keys (e.g. multi-checkbox) collapse into an array.
                const existing = values[key];
                values[key] = Array.isArray(existing) ? [...existing, str] : [existing, str];
            }
            else {
                values[key] = str;
            }
        }
        return { values, files, meta: { attribution, elapsedMs, pageUrl, captchaToken } };
    }
    // JSON body.
    const body = (await request.json().catch(() => null));
    if (!body)
        return null;
    const values = body.fields && typeof body.fields === 'object' && !Array.isArray(body.fields)
        ? body.fields
        : null;
    if (!values)
        return null;
    return {
        values,
        files: [],
        meta: {
            attribution: safeJsonObject(body.attribution),
            elapsedMs: typeof body.elapsedMs === 'number' && Number.isFinite(body.elapsedMs)
                ? body.elapsedMs
                : null,
            pageUrl: typeof body.pageUrl === 'string' ? body.pageUrl : null,
            captchaToken: typeof body.captchaToken === 'string' ? body.captchaToken : '',
        },
    };
}
export function safeJsonObject(value) {
    if (value && typeof value === 'object' && !Array.isArray(value)) {
        return value;
    }
    if (typeof value === 'string' && value.trim()) {
        try {
            const parsed = JSON.parse(value);
            if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
                return parsed;
            }
        }
        catch {
            /* ignore malformed attribution blob */
        }
    }
    return null;
}
/**
 * Shared public-submission handler used by both the slug-based public route and
 * the legacy by-id route. Enforces per-IP+form rate limiting, embed-origin
 * allowlisting, optional CAPTCHA, file validation, then runs the core
 * submission pipeline. Always responds with CORS headers so embedded forms can
 * read the result.
 */
export async function submitToForm(request, form) {
    const allowedDomains = form.embedSettings?.allowedDomains;
    const cors = formCorsHeaders(request, allowedDomains);
    const ip = getClientIp(request);
    const rlKey = isResolvedIp(ip) ? `formsubmit:${ip}:${form.id}` : `formsubmit:_unknown:${form.id}`;
    if (!(await checkRateLimitAsync(formLimiterGlobal, rlKey))) {
        return jsonWithHeaders({ error: 'Too many submissions. Please try again later.' }, 429, cors);
    }
    // Hard server-side origin check when an embed allowlist is configured —
    // browser CORS alone does not prevent a non-browser client from posting.
    if (allowedDomains && allowedDomains.length > 0) {
        const origin = request.headers.get('origin');
        if (origin && !isOriginAllowed(origin, allowedDomains)) {
            return jsonWithHeaders({ error: 'This form cannot be submitted from this origin.' }, 403, cors);
        }
    }
    const parsed = await parseSubmissionRequest(request);
    if (!parsed) {
        return jsonWithHeaders({ error: 'Invalid or missing submission body.' }, 400, cors);
    }
    const captchaConfig = getCaptchaConfig();
    if (captchaConfig.provider !== 'none') {
        const result = await verifyCaptcha(parsed.meta.captchaToken, captchaConfig, isResolvedIp(ip) ? ip : undefined);
        if (!result.success) {
            return jsonWithHeaders({ error: 'CAPTCHA verification failed. Please try again.' }, 403, cors);
        }
    }
    if (parsed.files.length > 0) {
        const fileCfg = getActuateConfig()?.forms?.fileUpload;
        if (fileCfg?.enabled === false) {
            return jsonWithHeaders({ error: 'File uploads are not enabled for this site.' }, 400, cors);
        }
        const { fields } = await resolveSubmissionFields(form);
        const fileCheck = validateSubmissionFiles(fields, parsed.files, {
            maxSizeBytes: fileCfg?.maxSizeMB ? fileCfg.maxSizeMB * 1024 * 1024 : DEFAULT_MAX_FILE_BYTES,
            allowedMimeTypes: fileCfg?.allowedMimeTypes ?? FORM_UPLOAD_ALLOWED_MIME_TYPES,
        });
        if (!fileCheck.ok) {
            return jsonWithHeaders({ error: 'File validation failed.', code: 'INVALID_FILES', details: fileCheck.errors }, 422, cors);
        }
    }
    const ctx = {
        values: parsed.values,
        files: parsed.files,
        ip,
        userAgent: request.headers.get('user-agent'),
        referrer: request.headers.get('referer'),
        origin: request.headers.get('origin'),
        pageUrl: parsed.meta.pageUrl,
        attribution: parsed.meta.attribution,
        elapsedMs: parsed.meta.elapsedMs,
    };
    try {
        const result = await processSubmission(form, ctx);
        return jsonWithHeaders({
            data: {
                success: true,
                submissionId: result.submissionId,
                entryNumber: result.entryNumber,
                confirmation: result.confirmation,
            },
        }, 201, cors);
    }
    catch (err) {
        if (err instanceof SubmissionValidationError) {
            return jsonWithHeaders({ error: 'Validation failed.', code: 'VALIDATION_ERROR', details: err.fieldErrors }, 422, cors);
        }
        return internalError(err);
    }
}
export function normalizeRedirect(redirect) {
    return {
        ...redirect,
        from: redirect.from ?? redirect.source ?? '',
        to: redirect.to ?? redirect.destination ?? '',
        type: String(redirect.type ?? redirect.statusCode ?? 301),
        hits: Number(redirect.hits ?? 0),
        active: redirect.active ?? true,
    };
}
export function normalizeSeoPage(page) {
    const data = asRecord(page.data);
    const structuredData = asRecord(page.structuredData);
    const issueValue = structuredData.issues ?? data.issues;
    const issueCount = Array.isArray(issueValue) ? issueValue.length : Number(issueValue ?? 0);
    const slug = String(data.slug ?? page.slug ?? page.id ?? '');
    const schemaType = data.schemaType ?? data.structuredDataType ?? structuredData.schemaType ?? '';
    return {
        ...page,
        url: data.url ?? (slug === 'home' ? '/' : `/${slug}`),
        title: data.title ?? page.title ?? 'Untitled',
        score: Number(structuredData.score ?? data.score ?? 0),
        issues: issueCount,
        metaTitle: data.metaTitle ?? '',
        metaDescription: data.metaDescription ?? '',
        canonical: data.canonical ?? data.canonicalUrl ?? '',
        schemaType,
    };
}
export function hasModel(d, name) {
    try {
        return d[name] && typeof d[name].findMany === 'function';
    }
    catch {
        return false;
    }
}
/** Format a number compactly, e.g. 8400 -> "8.4K", 1_200_000 -> "1.2M". */
export function formatCompactNumber(n) {
    if (!Number.isFinite(n))
        return '0';
    if (Math.abs(n) >= 1_000_000)
        return `${(n / 1_000_000).toFixed(1)}M`;
    if (Math.abs(n) >= 1_000)
        return `${(n / 1_000).toFixed(1)}K`;
    return String(Math.round(n));
}
/** Minimal CSV line parser supporting double-quoted fields with escaped quotes. */
export function parseCsvLine(line) {
    const out = [];
    let cur = '';
    let inQuotes = false;
    for (let i = 0; i < line.length; i++) {
        const ch = line[i];
        if (inQuotes) {
            if (ch === '"') {
                if (line[i + 1] === '"') {
                    cur += '"';
                    i++;
                }
                else {
                    inQuotes = false;
                }
            }
            else {
                cur += ch;
            }
        }
        else if (ch === '"') {
            inQuotes = true;
        }
        else if (ch === ',') {
            out.push(cur);
            cur = '';
        }
        else {
            cur += ch;
        }
    }
    out.push(cur);
    return out;
}
export function modelNotAvailable(name) {
    return errorResponse(`The "${name}" model is not available in your Prisma schema. ` +
        'Run `actuate db:init` for new schemas, or carefully update the existing Actuate block, create/apply a Prisma migration, then regenerate Prisma Client. ' +
        'See https://actuatecms.dev/docs/database-setup for required models.', 501);
}
/**
 * XML-escape a value for safe inclusion in sitemap content. Sitemaps reject
 * unescaped ampersands and angle brackets; URLs frequently contain `&` query
 * separators, so this is non-optional.
 */
export function escapeXml(value) {
    return value
        .replace(/&/g, '&amp;')
        .replace(/</g, '&lt;')
        .replace(/>/g, '&gt;')
        .replace(/"/g, '&quot;')
        .replace(/'/g, '&apos;');
}
/**
 * URLs per sitemap file. The sitemaps protocol caps a single file at 50,000
 * URLs; we use a margin below that so the optional archive URL on page 0 can
 * never push a file over the limit. Collections larger than this are split
 * across multiple `?page=N` files referenced from the sitemap index.
 */
export const SITEMAP_PAGE_SIZE = 45000;
/**
 * Renders a 1200x630 SVG suitable for og:image. We don't pull in Satori/resvg
 * because most integrators don't need PNG — major crawlers (Facebook, Twitter,
 * LinkedIn, Slack, Discord) handle image/svg+xml correctly. Sites that
 * specifically need PNG can override with their own /og endpoint via
 * @vercel/og and point `seo.defaultOgImage` at it.
 */
export function renderOgSvg(opts) {
    const { title, description, siteName, bg, fg, muted } = opts;
    // Naive line wrapping at ~22 chars (large font). Good enough for an OG card;
    // anything longer than ~3 lines gets truncated with an ellipsis.
    const wrap = (text, maxChars, maxLines) => {
        const words = text.split(/\s+/);
        const lines = [];
        let current = '';
        for (const w of words) {
            if (lines.length >= maxLines)
                break;
            const next = current ? `${current} ${w}` : w;
            if (next.length > maxChars) {
                if (current)
                    lines.push(current);
                current = w;
                if (lines.length === maxLines - 1 && words.indexOf(w) < words.length - 1) {
                    lines.push(current.length > maxChars ? current.slice(0, maxChars - 1) + '…' : current + '…');
                    current = '';
                    break;
                }
            }
            else {
                current = next;
            }
        }
        if (current && lines.length < maxLines)
            lines.push(current);
        return lines;
    };
    const escapeSvg = (s) => s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
    const titleLines = wrap(title, 28, 3);
    const descLines = description ? wrap(description, 60, 2) : [];
    const titleEls = titleLines
        .map((line, i) => `<tspan x="60" dy="${i === 0 ? 0 : 78}">${escapeSvg(line)}</tspan>`)
        .join('');
    const descEls = descLines
        .map((line, i) => `<tspan x="60" dy="${i === 0 ? 0 : 36}">${escapeSvg(line)}</tspan>`)
        .join('');
    // Layout: site name top-left, title bottom-left, description below title.
    // Coordinates are roughly aligned to the 1200x630 spec used by every major
    // social platform.
    const titleY = descLines.length > 0 ? 360 : 420;
    const descY = titleY + 80 * titleLines.length;
    return `<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="630" viewBox="0 0 1200 630">
  <rect width="1200" height="630" fill="${bg}"/>
  ${siteName ? `<text x="60" y="100" font-family="system-ui, -apple-system, Segoe UI, Arial, sans-serif" font-size="28" font-weight="500" fill="${muted}">${escapeSvg(siteName)}</text>` : ''}
  <text x="60" y="${titleY}" font-family="system-ui, -apple-system, Segoe UI, Arial, sans-serif" font-size="68" font-weight="700" fill="${fg}">${titleEls}</text>
  ${descLines.length > 0 ? `<text x="60" y="${descY}" font-family="system-ui, -apple-system, Segoe UI, Arial, sans-serif" font-size="30" fill="${muted}">${descEls}</text>` : ''}
  <rect x="60" y="560" width="60" height="6" fill="${fg}" rx="3"/>
</svg>`;
}
/**
 * Log unexpected query failures from the `safe*` wrappers.
 *
 * The wrappers exist so an optional Prisma model (e.g. a delegate
 * that older schemas don't define) doesn't blow up the route — they
 * return the empty value (`0` / `[]`) when the model isn't available.
 * That defensive contract historically swallowed *every* error,
 * including Prisma's "Unknown argument" rejections that flag a real
 * application bug (see the `relationLoadStrategy` regression
 * discovered while landing Phase 5.5 #5). Logging the underlying
 * error preserves the defensive return semantics while making the
 * actual class of bug visible in Vercel + Sentry logs.
 *
 * Kept private to this module — it's a logging contract, not a
 * public API.
 */
export function logSafeQueryFailure(scope, err) {
    // Defensive: never throw from a logger that wraps `safe*` queries
    // — the whole point of those wrappers is to keep the request hot
    // path alive.
    try {
        const message = err instanceof Error ? err.message : String(err);
        console.warn(`[actuate][cms-api] ${scope} query failed: ${message}`);
    }
    catch {
        /* swallow */
    }
}
export async function safeCount(model, where) {
    if (!model || typeof model !== 'object')
        return 0;
    let countFn;
    try {
        countFn = model.count;
    }
    catch {
        return 0;
    }
    if (typeof countFn !== 'function')
        return 0;
    try {
        return await countFn.call(model, where ? { where } : undefined);
    }
    catch (err) {
        logSafeQueryFailure('safeCount', err);
        return 0;
    }
}
export async function safeFindMany(model, args) {
    if (!model || typeof model !== 'object')
        return [];
    let findManyFn;
    try {
        findManyFn = model.findMany;
    }
    catch {
        return [];
    }
    if (typeof findManyFn !== 'function')
        return [];
    try {
        return await findManyFn.call(model, args);
    }
    catch (err) {
        logSafeQueryFailure('safeFindMany', err);
        return [];
    }
}
export function isAllowedStorageUrl(url) {
    try {
        const parsed = new URL(url);
        if (!['https:', 'http:'].includes(parsed.protocol))
            return false;
        const h = parsed.hostname;
        if (h === 'localhost' || h === '127.0.0.1' || h === '::1')
            return false;
        if (h.startsWith('10.') || h.startsWith('192.168.'))
            return false;
        if (/^172\.(1[6-9]|2\d|3[01])\./.test(h))
            return false;
        if (h === '169.254.169.254')
            return false;
        if (h.endsWith('.internal') || h.endsWith('.local'))
            return false;
        return true;
    }
    catch {
        return false;
    }
}
/** Max image size (bytes) we'll pull into memory for vision analysis. */
export const MAX_VISION_IMAGE_BYTES = 8 * 1024 * 1024;
/**
 * Fetch image bytes for multimodal (vision) analysis through {@link safeFetch}
 * (SSRF-safe: re-validates the host, DNS-resolves to defeat rebinding, no
 * redirects). Returns `null` on any failure (non-image, oversize, blocked,
 * network) so the caller transparently falls back to a context-only heuristic.
 */
export async function fetchImageForVision(url, knownMimeType) {
    try {
        const resp = await safeFetch(url, { method: 'GET', timeoutMs: 5000 });
        if (!resp.ok)
            return null;
        const contentType = ((resp.headers.get('content-type') ?? knownMimeType ?? '').split(';')[0] ?? '').trim();
        if (!contentType.startsWith('image/'))
            return null;
        const declaredLength = Number(resp.headers.get('content-length') ?? '0');
        if (declaredLength > MAX_VISION_IMAGE_BYTES)
            return null;
        const buffer = Buffer.from(await resp.arrayBuffer());
        if (buffer.byteLength === 0 || buffer.byteLength > MAX_VISION_IMAGE_BYTES)
            return null;
        return { base64: buffer.toString('base64'), mimeType: contentType };
    }
    catch {
        return null;
    }
}
export const ALLOWED_SORT_FIELDS = new Set([
    'createdAt',
    'updatedAt',
    'publishedAt',
    'status',
    'collection',
]);
let _secretMissing = false;
let _secretWarningLogged = false;
export function getSessionSecret() {
    const secret = process.env.CMS_SECRET ?? process.env.CMS_SESSION_SECRET ?? getActuateConfig()?.secret;
    if (!secret) {
        _secretMissing = true;
        if (!_secretWarningLogged) {
            _secretWarningLogged = true;
            console.error('[Actuate CMS] Missing CMS secret. Set the CMS_SECRET environment variable (min 32 characters) ' +
                'or pass `secret` in your actuate.config.ts. ' +
                "Generate one with: node -e \"console.log(require('crypto').randomBytes(32).toString('hex'))\" " +
                '-- All authenticated API routes will return 503 until this is configured.');
        }
        throw new Error('CMS secret not configured');
    }
    if (secret.length < 32) {
        throw new Error('[Actuate CMS] CMS secret must be at least 32 characters (got ' +
            secret.length +
            '). ' +
            "Generate a secure value with: node -e \"console.log(require('crypto').randomBytes(32).toString('hex'))\"");
    }
    _secretMissing = false;
    return secret;
}
export function isSecretMissing() {
    if (_secretMissing)
        return true;
    try {
        getSessionSecret();
        return false;
    }
    catch {
        return true;
    }
}
export async function extractSession(request) {
    if (isSecretMissing())
        return null;
    let token;
    const authHeader = request.headers.get('Authorization');
    if (authHeader?.startsWith('Bearer ')) {
        token = authHeader.slice(7);
    }
    if (!token) {
        const cookieHeader = request.headers.get('cookie') ?? '';
        const cookies = parseCookieHeader(cookieHeader);
        token = cookies['actuate_session'];
    }
    if (!token)
        return null;
    // API key path. Keys are recognized by the `act_sk_` prefix and looked up
    // by SHA-256 hash. We never JWT-verify these tokens — they are opaque
    // random secrets stored as hashes in `actuate_api_keys`.
    if (looksLikeApiKey(token)) {
        try {
            const d = getDB();
            if (!hasModel(d, 'apiKey'))
                return null;
            const hash = await hashApiKey(token);
            const apiKey = await d.apiKey.findUnique({ where: { keyHash: hash } });
            if (!apiKey)
                return null;
            if (apiKey.revokedAt)
                return null;
            if (apiKey.expiresAt && new Date(apiKey.expiresAt).getTime() < Date.now())
                return null;
            const ipRestrictions = Array.isArray(apiKey.ipRestrictions)
                ? apiKey.ipRestrictions
                : apiKey.ipRestrictions
                    ? (apiKey.ipRestrictions.allow ?? null)
                    : null;
            if (ipRestrictions && ipRestrictions.length > 0) {
                const ip = getClientIp(request);
                if (!validateApiKeyIp(ipRestrictions, ip))
                    return null;
            }
            const scopes = apiKey.scopes ?? {};
            // Fire-and-forget lastUsedAt update; never block the request on it.
            void d.apiKey
                .update({ where: { id: apiKey.id }, data: { lastUsedAt: new Date() } })
                .catch(() => { });
            return {
                userId: apiKey.userId,
                role: scopes.admin ? 'ADMIN' : 'API_KEY',
                sessionId: apiKey.id,
                apiKey: { id: apiKey.id, scopes },
            };
        }
        catch {
            return null;
        }
    }
    // Session JWT path.
    try {
        const payload = await verifySession(token, { secret: getSessionSecret() });
        const d = getDB();
        if (hasModel(d, 'session')) {
            const dbSession = await d.session.findUnique({
                where: { id: payload.sessionId },
                select: { revokedAt: true },
            });
            if (!dbSession || dbSession.revokedAt)
                return null;
        }
        return payload;
    }
    catch {
        return null;
    }
}
export function parseCookieHeader(cookieHeader) {
    const cookies = {};
    for (const pair of cookieHeader.split(';')) {
        const [key, ...rest] = pair.split('=');
        if (key) {
            cookies[key.trim()] = rest.join('=').trim();
        }
    }
    return cookies;
}
/**
 * Verify a password (or "current password") posted in the X-Reauth-Password
 * header against the authenticated user. Returns an error Response when the
 * header is missing or the password is wrong; returns null on success.
 *
 * Use this for high-impact account changes (TOTP enable/disable, role change,
 * delete user, etc.) so a stolen session alone cannot perform them.
 */
export async function requirePasswordReauth(request, userId) {
    const password = request.headers.get('x-reauth-password');
    if (!password) {
        return new Response(JSON.stringify({
            error: 'Re-authentication required for this action.',
            code: 'REAUTH_REQUIRED',
            method: 'password',
        }), { status: 401, headers: { ...SECURITY_HEADERS } });
    }
    try {
        const { getDB } = await import('../db.js');
        const ok = await verifyReauth(userId, password, 'password', getDB());
        if (!ok) {
            return errorResponse('Re-authentication failed.', 401);
        }
        return null;
    }
    catch (err) {
        console.error('[actuate][reauth] verify failed:', err instanceof Error ? err.message : err);
        return errorResponse('Re-authentication failed.', 401);
    }
}
export async function requireAuth(request) {
    if (isSecretMissing()) {
        return {
            error: json({
                error: 'CMS secret not configured. Set CMS_SECRET or CMS_SESSION_SECRET environment variable (min 32 characters).',
                code: 'MISSING_SECRET',
            }, 503),
        };
    }
    const session = await extractSession(request);
    if (!session) {
        return { error: errorResponse('Unauthorized', 401) };
    }
    return { session };
}
/**
 * Check that the request's auth context permits `action` on `collection`.
 * - Session-authenticated requests fall through to `requireRole` semantics:
 *   write actions require WRITE_ROLES.
 * - API-key-authenticated requests must have an explicit scope match.
 */
export function requireCollectionScope(session, collection, action) {
    if (session.apiKey) {
        if (!validateApiKeyScope(session.apiKey.scopes, collection, action)) {
            return errorResponse(`API key does not have permission to ${action} on collection "${collection}"`, 403);
        }
        return null;
    }
    if (action === 'read')
        return null;
    return requireRole(session.role, WRITE_ROLES);
}
export function requireGlobalScope(session, slug) {
    if (session.apiKey) {
        if (!validateApiKeyGlobalScope(session.apiKey.scopes, slug)) {
            return errorResponse(`API key does not have permission for global "${slug}"`, 403);
        }
    }
    return null;
}
export function requireMediaScope(session) {
    if (session.apiKey) {
        if (!validateApiKeyMediaScope(session.apiKey.scopes)) {
            return errorResponse('API key does not have media scope', 403);
        }
    }
    return null;
}
export function requirePageBuilderScope(session) {
    if (session.apiKey) {
        if (!validateApiKeyPageBuilderScope(session.apiKey.scopes)) {
            return errorResponse('API key does not have pageBuilder scope', 403);
        }
        return null;
    }
    return requireRole(session.role, ADMIN_ROLES);
}
export function requireAdminScope(session) {
    if (session.apiKey) {
        if (!session.apiKey.scopes.admin) {
            return errorResponse('API key does not have admin scope', 403);
        }
        return null;
    }
    return requireRole(session.role, ADMIN_ROLES);
}
export function buildActionContext(session, db, locale) {
    return {
        userId: session.userId,
        role: session.role,
        locale,
        db,
    };
}
export const WRITE_ROLES = new Set(['OWNER', 'ADMIN', 'EDITOR', 'AUTHOR']);
export const ADMIN_ROLES = new Set(['OWNER', 'ADMIN']);
export function requireRole(role, allowedRoles) {
    if (!allowedRoles.has(role)) {
        return errorResponse('Insufficient permissions', 403);
    }
    return null;
}
/**
 * Build the shared {@link TeamOptions} for the Users endpoints from config +
 * session: the workspace domain (for external-domain detection) and the seat
 * cap (config-provided; null = unlimited until billing exists).
 */
export function teamOptionsFor(session) {
    const cfg = getActuateConfig();
    const siteUrl = process.env.NEXT_PUBLIC_SITE_URL ??
        cfg?.seo?.siteUrl ??
        null;
    const seatLimit = cfg?.users?.seatLimit ??
        cfg?.admin?.seatLimit ??
        null;
    return {
        workspaceDomain: workspaceDomainFromUrl(siteUrl),
        seatLimit,
        currentUserId: session.userId,
    };
}
/** Whether the configured email provider can actually send mail. */
export function isEmailConfigured() {
    const cfg = getActuateConfig();
    if (cfg?.platform?.email)
        return true;
    return !!resolveEmailSender();
}
/**
 * DB-aware variant of {@link isEmailConfigured} — also true when a provider
 * is configured through Settings > Email.
 */
export async function isEmailConfiguredAsync(db) {
    if (isEmailConfigured())
        return true;
    const { resolveEmailSenderAsync } = await import('../email/resolve.js');
    return Boolean(await resolveEmailSenderAsync(db));
}
export const loginLimiter = createRateLimiter({ maxRequests: 5, windowMs: 15 * 60 * 1000 });
// Admin-gated, but bounds invitation-email volume (and accidental loops) per
// admin: enough to onboard a team, low enough to deter abuse.
export const inviteLimiter = createRateLimiter({ maxRequests: 20, windowMs: 60 * 60 * 1000 });
export const totpLimiter = createRateLimiter({ maxRequests: 10, windowMs: 15 * 60 * 1000 });
// Admin-gated test sends from Settings > Email — enough to debug a provider
// setup, low enough that a compromised admin session can't spray mail.
export const emailTestLimiter = createRateLimiter({ maxRequests: 10, windowMs: 60 * 60 * 1000 });
export const formLimiterGlobal = createRateLimiter({ maxRequests: 10, windowMs: 60_000 });
export const aiGenerateLimiter = createRateLimiter({ maxRequests: 20, windowMs: 60 * 60 * 1000 });
// Strict per-user upload cap (security rule: uploads strict at 20/hour) — guards
// storage cost and optimization abuse beyond the global per-IP API bucket.
export const uploadLimiter = createRateLimiter({ maxRequests: 20, windowMs: 60 * 60 * 1000 });
// Deterministic (no provider) AI-adjacent routes that scan/iterate documents.
// Cheaper than the generation budget but still bounded against cost/DoS.
export const aiAnalysisLimiter = createRateLimiter({ maxRequests: 60, windowMs: 60 * 60 * 1000 });
// Upper bound on free-text inputs to the deterministic analysis routes
// (document body / link-candidate content) — caps memory + scan work.
export const AI_ANALYSIS_MAX_CHARS = 50_000;
export const linkHealthLimiter = createRateLimiter({ maxRequests: 4, windowMs: 60 * 60 * 1000 });
export const seoAuditLimiter = createRateLimiter({ maxRequests: 10, windowMs: 60 * 60 * 1000 });
export async function checkRateLimitAsync(limiter, key) {
    // Explicit, environment-gated bypass for test harnesses. Production never
    // sets this — Vercel + the deploy guide both omit it. We deliberately do
    // NOT key off `NODE_ENV === 'test'` because Next.js builds run with
    // NODE_ENV=test in some CI configurations and we want rate limiting
    // exercised by unit tests that explicitly opt in.
    if (process.env.ACTUATE_DISABLE_RATE_LIMIT === '1') {
        return true;
    }
    const result = await limiter.check(key);
    return result.allowed;
}
/**
 * Resolve the client IP from trusted-proxy headers (Vercel first, then x-real-ip,
 * then x-forwarded-for only when ACTUATE_TRUST_PROXY=1). Returns 'unknown' when
 * no trustworthy source is available — callers MUST treat that case as a hard
 * failure for security-sensitive decisions like rate-limit keys and IP allowlists.
 */
export function clientIp(request) {
    return getClientIp(request);
}
/**
 * Compute a before/after diff of top-level scalar (string/number/boolean) keys
 * between a stored record and an incoming patch. Used to audit settings changes
 * without ever logging nested objects (which may hold secret blobs).
 */
export function diffScalarKeys(previous, next) {
    const isScalar = (v) => typeof v === 'string' || typeof v === 'number' || typeof v === 'boolean' || v === null;
    const changes = [];
    for (const key of Object.keys(next)) {
        const to = next[key];
        if (!isScalar(to))
            continue;
        const from = previous[key];
        if (isScalar(from) && from === to)
            continue;
        if (!isScalar(from) && from !== undefined)
            continue;
        changes.push({ key, from: isScalar(from) ? from : undefined, to });
    }
    return changes;
}
export const MAX_CONCURRENT_SESSIONS = 5;
/**
 * After a successful primary-credential check, prune the user's active sessions
 * down to the configured concurrent maximum (default 5; configurable via
 * `auth.maxConcurrentSessions`). Strategy is "revoke oldest" so a user can
 * always sign in.
 */
export async function enforceSessionLimitsForUser(d, userId) {
    if (!hasModel(d, 'session'))
        return;
    // Precedence: explicitly-saved Security setting (database) ▸ auth config ▸
    // default. We read the stored value directly (rather than the parsed
    // defaults) so an unsaved policy doesn't shadow a config-provided override.
    let storedMax;
    try {
        if (hasModel(d, 'document')) {
            const row = await d.document.findFirst({
                where: { collection: 'settings', deletedAt: null },
                select: { data: true },
            });
            const stored = row?.data?.security;
            const v = stored?.session?.maxConcurrentSessions;
            if (typeof v === 'number' && Number.isInteger(v) && v >= 0)
                storedMax = v;
        }
    }
    catch {
        /* fall back to config/default below */
    }
    const auth = getActuateConfig()?.auth;
    const max = storedMax !== undefined
        ? storedMax
        : typeof auth?.maxConcurrentSessions === 'number' && auth.maxConcurrentSessions > 0
            ? auth.maxConcurrentSessions
            : MAX_CONCURRENT_SESSIONS;
    // 0 ⇒ unlimited: skip pruning entirely.
    if (max <= 0)
        return;
    const active = await d.session.findMany({
        where: { userId, revokedAt: null, expiresAt: { gt: new Date() } },
        select: { id: true, createdAt: true },
    });
    const decision = enforceSessionLimits(active.map((s) => ({ sessionId: s.id, userId, createdAt: s.createdAt })), { maxConcurrentSessions: max, strategy: 'revoke_oldest' });
    if (decision.sessionsToRevoke.length > 0) {
        await d.session.updateMany({
            where: { id: { in: decision.sessionsToRevoke } },
            data: { revokedAt: new Date() },
        });
    }
}
export function getAdminPath() {
    return process.env.ACTUATE_ADMIN_PATH ?? getActuateConfig()?.admin?.path ?? '/admin';
}
/**
 * Resolve a collection definition from the runtime config, tolerating both
 * the array and the record (keyed-by-slug) shapes consumers use.
 */
export function findCollectionDef(slug) {
    const collections = getActuateConfig()?.collections;
    if (Array.isArray(collections)) {
        return collections.find((c) => c?.slug === slug);
    }
    if (collections && typeof collections === 'object') {
        const rec = collections;
        return rec[slug] ?? Object.values(rec).find((c) => c?.slug === slug);
    }
    return undefined;
}
/**
 * True when `collection` declares a `sections` JSON field — the storage
 * contract the flat Page Editor + section endpoints rely on. Guards against
 * silently dropping section writes on a collection that never declared the
 * field (the field-access layer strips undeclared keys).
 */
export function collectionSupportsSections(slug) {
    const def = findCollectionDef(slug);
    if (!def)
        return false;
    const fields = def.fields;
    if (Array.isArray(fields)) {
        return fields.some((f) => f?.name === 'sections' && f?.type === 'json');
    }
    if (fields && typeof fields === 'object') {
        const f = fields.sections;
        return f?.type === 'json';
    }
    return false;
}
/**
 * Gate a per-document read for endpoints (e.g. SEO analysis) that fetch a
 * document directly rather than through the collection CRUD routes. Enforces
 * both the API-key collection scope and the collection's `access.read` rule
 * so an AUTHOR scoped to one collection cannot read another's content.
 * Returns a `Response` to short-circuit with, or `null` when allowed.
 */
export async function enforceDocumentReadAccess(session, collection) {
    const scopeErr = requireCollectionScope(session, collection, 'read');
    if (scopeErr)
        return scopeErr;
    try {
        await assertCollectionAccess(collection, 'read', buildActionContext(session, guardedDb()));
    }
    catch (err) {
        if (err instanceof Error && err.message.includes('Access denied')) {
            return errorResponse('Insufficient permissions', 403);
        }
        throw err;
    }
    return null;
}
//# sourceMappingURL=route-helpers.js.map