/** * Helpers over the generated route catalogue. * * The catalogue is display and validation data — enforcement matches route * grants against the raw request path, not against this list. Keeping the * two apart means a route the generator missed is still gated (by falling * outside every grant) rather than silently open. */ import { compileRouteGrant, type PolicyRules } from "@premium-cms/auth"; import { ROUTE_CATALOG, type RouteCatalogEntry } from "./route-catalog.generated.js"; export { ROUTE_CATALOG, type RouteCatalogEntry }; const API_PREFIX_RE = /^\/_emdash\/api/; const REST_PARAM_RE = /\[\.\.\.[^\]]+\]/g; const PARAM_RE = /\[[^\]]+\]/g; /** Astro pattern → concrete-ish example path, e.g. [collection] → posts. */ export function patternToExample(pattern: string): string { return pattern.replace(API_PREFIX_RE, "").replace(REST_PARAM_RE, "a/b").replace(PARAM_RE, "x"); } /** Astro pattern → the path glob a policy would write to cover exactly it. */ export function patternToGrantPath(pattern: string): string { const rel = pattern.replace(API_PREFIX_RE, "") || "/"; return rel.replace(REST_PARAM_RE, "**").replace(PARAM_RE, "*"); } /** Catalogue entries that a set of policy rules would grant. */ /** * Catalogue entries for the routes enabled plugins expose. Built per request * (plugins come and go) and grouped as `plugins/` so the admin lists * them beside the core groups. */ export function pluginCatalogEntries( routes: Array<{ pluginId: string; route: string; public: boolean; permission: string }>, ): RouteCatalogEntry[] { return routes.map((r) => { const pattern = `/_emdash/api/plugins/${r.pluginId}/${r.route}`; return { id: `POST ${pattern.replace(API_PREFIX_RE, "")}`, method: "POST", pattern, file: `plugin:${r.pluginId}`, group: `plugins/${r.pluginId}`, permissions: r.public ? [] : [r.permission], adminOnly: false, public: r.public, }; }); } export function entriesGrantedBy(rules: PolicyRules, extra: readonly RouteCatalogEntry[] = []): RouteCatalogEntry[] { const catalog = [...ROUTE_CATALOG, ...extra]; const grants = rules.routes ?? []; if (grants.some((g) => g.trim() === "*")) return catalog; const matchers = grants .map(compileRouteGrant) .filter((m): m is NonNullable => m !== null); const denies = (rules.deny?.routes ?? []) .map(compileRouteGrant) .filter((m): m is NonNullable => m !== null); return catalog.filter((entry) => { const example = patternToExample(entry.pattern); const denied = denies.some( (d) => (d.method === "*" || d.method === entry.method) && d.pattern.test(example), ); if (denied) return false; return matchers.some( (m) => (m.method === "*" || m.method === entry.method) && m.pattern.test(example), ); }); } export function catalogGroups(extra: readonly RouteCatalogEntry[] = []): string[] { return [...new Set([...ROUTE_CATALOG, ...extra].map((e) => e.group))].toSorted(); }