# @actuate-media/cms-core

## 0.81.0

### Minor Changes

- b8b930b: Drop the unused `searchVector` column (FTS uses `plainText` + `title`), add optional Stripe Billing Meter hooks for AI token and SEO AI usage, and document Prisma Postgres backup retention for DR.

## 0.80.1

### Patch Changes

- af18915: Migrate API handler, actions, cron, resolve, media, and auth hot paths from raw `console.*` to the structured logger with route-scoped error details on unhandled API failures.

## 0.80.0

### Minor Changes

- 5fce703: Enable AI monthly token budget enforcement when a limit is configured. Add plan-tier feature gating for advanced SEO Operations Center features (inline apply, content briefs, research, batch briefs) with admin upgrade messaging and pricing tier documentation.

## 0.79.3

### Patch Changes

- 8229d94: Wire Sentry for server API errors and admin ErrorBoundary reporting. Extend the daily cleanup cron to prune document versions (90 days), webhook delivery logs (30 days), and API request metrics (30 days). Add disaster recovery runbook documentation.

## 0.79.2

### Patch Changes

- 2966293: Strict rate limiters (login, TOTP, AI, uploads) fail closed when Upstash Redis is missing or errors in production; general API rate limiting remains fail-open (roadmap #393).

## 0.79.1

### Patch Changes

- 0ad67c9: Enforce collection `access.read` on document GET/list actions and GraphQL resolvers. Returns 403 when the rule denies the caller (roadmap #392).

## 0.79.0

### Minor Changes

- 39e2419: Phase 1–2: inline SEO audit fixes (deterministic + AI approve). Phase A: markdown export of manual/content fixes for dev-team handoff. Phase B: AI content briefs for thin content, heading, and alt-text issues. Phase C: topic research subagent, research-and-brief flow, and batch brief pre-generation. MCP: seven new tools for the audit fix workflow (`export_seo_fix_brief`, suggest/apply fix, content briefs, research-and-brief, batch generate) plus server instructions.

## 0.78.0

### Minor Changes

- bc51a1d: Align SEO audit with live page meta output, expand audit checks, wire redirect/link signals, and add `actuate migrate:meta` for bulk meta backfill.

## 0.77.3

### Patch Changes

- 9d4e24c: Hide the redundant site name headline on the login screen and in the admin sidebar when a logo image is displayed.

  Serve favicons as properly sized 32×32 PNGs (and 180×180 apple-touch icons) from dedicated public routes so SVG and oversized sources render correctly in browser tabs.

  Align SEO audit schema/canonical detection with public page rendering, add `actuate migrate:schema` to backfill structuredDataType on existing content, and fix route-contract tests for new favicon endpoints.

- 869620b: Align SEO audit, render, and Content SEO editing: auto-canonical and effective schema type resolution match `composePageMeta()`, schema fields sync on save, Content SEO drawer adds schema type, plugin removes dead `beforeCreate` hook and re-exports cms-core schema detection.

## 0.77.2

### Patch Changes

- 9875fa3: Omit temperature for reasoning and Opus 4.7+ models in the governed AI runtime so brand voice analyze and other features work when the default model is Claude Opus. Surface a clearer admin error when providers reject sampling parameters.

## 0.77.1

### Patch Changes

- 4dafd90: Fix brand voice analyze failures when Settings > AI uses legacy Anthropic curated model ids (`claude-*-4-latest`): remap saved ids to current API model strings, refresh the curated fallback catalog, surface provider error details on HTTP 400/404, and add plugin-ai fallback plus a setup readiness banner when governed AI is incomplete.

## 0.77.0

### Minor Changes

- d97e388: Add brand voice analysis: scan published pages and posts to draft a profile via `POST /ai/brand-voice/analyze`, with a Generate from content panel in Settings > AI.
- d97e388: Brand voice Phase 2: portable `brand-voice-profile.md` documents with export/import, an advanced markdown editor, and server-side sync between the document and quick-edit fields.
- d97e388: Brand voice Phase 3: structured rule editors (vocabulary, channel overrides, worked examples), deterministic alignment scoring via `POST /ai/brand-voice/score`, and a check-alignment panel in Settings > AI.
- d97e388: Phase 4 brand voice: wire alignment scoring into co-author and SEO field generation, add analyze-and-fix endpoint with hybrid deterministic + AI rewrite, and extend profile analyzer with SSRF-safe URL fetch and text file uploads.

## 0.76.3

### Patch Changes

- 7a6a140: Support Vercel Marketplace Blob auth via OIDC (`BLOB_STORE_ID` + `VERCEL_OIDC_TOKEN`) instead of requiring `BLOB_READ_WRITE_TOKEN`. Fix `@vercel/blob` client options to use `oidcToken` + `storeId` (never pass OIDC as `token`). Update env diagnostics, upload errors, and `actuate vercel:blob-link` accordingly.

## 0.76.2

### Patch Changes

- 90b5826: Allow `prisma generate` without DATABASE_URL by using optional env in prisma.config.ts (CI and fresh clones).

## 0.76.1

### Patch Changes

- 4899af9: Fix consumer production builds on Next.js 16 by not injecting a no-op webpack hook in `withActuateCMS`. Add `CRON_SECRET` to scaffold `.env.example`. Remove obsolete `earlyAccess` from scaffolded `prisma.config.ts`. Load project `.env` files in `actuate doctor` and `actuate deploy:check`.
- df1176a: Remove deprecated `earlyAccess: true` from Prisma config (Prisma 7.8 TypeScript compatibility).

## 0.76.0

### Minor Changes

- c656ddc: Add an `embed` (video) section type for YouTube, Vimeo, and Loom.
  - **cms-core:** new `embed` section in the registry (`url`, `title`, `ratio`, `caption`) plus a shared, deterministic `parseEmbed()` provider allowlist (`@actuate-media/cms-core/page-sections`). Section validation rejects any URL that isn't a recognised YouTube/Vimeo/Loom link, so an arbitrary URL can never reach an iframe.
  - **sections-react:** `EmbedSection` renderer — a lazy-loaded provider iframe inside a fixed aspect-ratio box (16:9 / 4:3 / 1:1 / 9:16) so the embed reserves its space and never causes layout shift (CLS). The URL is re-parsed through the same allowlist at render time.
  - **cms-admin:** matching editor-canvas body for `embed` (provider + ratio placeholder) so the page/post editors preview the section instead of showing a generic "custom section" placeholder.
  - **mcp-server:** `list_section_types` description now lists `embed`.

## 0.75.0

### Minor Changes

- 5446671: SEO sitemap: wire up the "Include images in sitemap" crawl setting and remove two non-functional toggles.
  - **cms-core:** the per-collection XML sitemap now emits Google image-sitemap `<image:image>` entries for each document's featured image and any `hero`/`feature`/`gallery` section images when "Include images in sitemap" is enabled (the default). URLs are absolutized against the site origin, deduped, and capped per page; the `xmlns:image` namespace is declared only when at least one image is present.
  - **cms-admin + cms-core:** removed the `noindexPaginatedArchives` and `noindexTagPages` crawl toggles. They round-tripped through storage but were never applied — paginated/tag archive pages are rendered by the consumer's frontend, which the CMS doesn't control, so these could not be enforced server-side. Removing them avoids a misleading no-op in Settings → SEO → Technical.

## 0.74.0

### Minor Changes

- 2b61b66: Core Web Vitals: give the `hero` section an optional image rendered as the page LCP, and serve AVIF in scaffolded sites.
  - **Hero image (LCP):** the canonical `hero` section now has an optional `imageUrl`/`imageAlt` field. When set, the renderer draws it below the copy as the page's single priority (eager, `fetchpriority=high`) image with a full-width `sizes` hint and intrinsic dimensions — the explicit, dimensioned LCP element the best-practice spec calls for (rather than leaving the LCP to chance). Alt text is required when an image is set (existing accessibility rule). Dimensions are backfilled by the `/resolve` enrichment (hero is now enriched like `feature`).
  - **AVIF delivery:** `create-actuate-cms` now sets `images.formats: ['image/avif', 'image/webp']` so scaffolded sites serve AVIF (≈50% smaller than JPEG) first, then WebP — a direct LCP win via `next/image` format negotiation.

## 0.73.0

### Minor Changes

- 7b7f0be: Core Web Vitals: get intrinsic image dimensions onto every image so `next/image` engages and CLS is eliminated.

  Without `width`/`height`, consumers silently fall back to an unsized `<img>` (no srcset, layout shift). This closes that gap two ways:
  - **Read-time (`/resolve`) enrichment** now covers the **post featured image** and **`hero` section images** (previously only `feature`/`gallery`). The resolver looks up dimensions + alt from `actuate_media` and returns the featured image as `post.featuredImage = { url, width, height, alt }` (new `ResolveFeaturedImage` type). Exposes a `featuredImageUrl()` helper. Purely additive — the stored document is never mutated.
  - **Author-time persistence**: selecting an image in the section editor's media picker now writes the intrinsic `width`/`height` alongside the URL (`imageUrl` → `imageWidth`/`imageHeight`; gallery `url` → `width`/`height`), so dimensions travel with the document even without `/resolve`. Exposes `companionDimensionKeys()`.

  `create-actuate-cms` + `apps/dev`: the generated post page now feeds the resolved featured-image dimensions into the post header for a CLS-free, dimensioned LCP image.

## 0.72.1

### Patch Changes

- 2fa4ebb: Fix admin brand logo: always show the logo (never the favicon), make it responsive, and degrade gracefully on broken images.
  - **cms-core**: `resolveAdminBrand` no longer falls back to the favicon source for the sidebar mark. The favicon is a browser-tab icon, and using it in the admin made the brand appear to "switch" between the logo and the favicon (collapsed rail and the brief no-primary boot state). With no dedicated logo mark, the admin mark is now `null` so the sidebar falls back to the primary logo.
  - **cms-admin**: The sidebar `BrandLogo` now caps logo width (`max-w-full`) so wide horizontal logos scale down instead of overflowing, prefers the logo in the collapsed rail, and attaches `onError` handlers that fall through the logo chain (and finally the neutral placeholder) instead of rendering the browser's broken-image icon when an asset URL is broken/expired/deleted. The Settings → Appearance upload preview also falls back to the placeholder icon on image load failure.

## 0.72.0

### Minor Changes

- 6f140bc: Auto-wire the `platform.storage` shorthand to a real adapter server-side, and keep `actuate.config.ts` client-safe.

  `actuate.config.ts` is imported by the admin client bundle, so putting a platform adapter (e.g. `platform: vercel()`) in it pulls `node:*`/`@vercel/blob` into the client build and breaks it with `Module not found: node:fs`. The config must stay adapter-free.
  - **cms-core**: When the API handler boots and sees a remote `platform.storage` shorthand (`vercel-blob` → `@actuate-media/platform-vercel`, `s3` → `@actuate-media/platform-aws`) with no adapter registered, it now dynamically imports the matching platform package and registers its storage adapter server-side. The import specifier is computed so bundlers never try to statically resolve an optional peer; a missing package degrades to a clear guidance warning instead of a silent upload failure. An explicitly registered adapter always wins. `setStorageAdapter` / `getStorageAdapter` are now also re-exported from `@actuate-media/cms-core/api` for server-side registration of custom adapters.
  - **cms-core**: Clearer upload/diagnostics errors — when a Vercel Blob store is linked (`BLOB_STORE_ID`) but `BLOB_READ_WRITE_TOKEN` is missing, messages now explain that Vercel does not provide the token via OIDC and it must be added per-environment.
  - **create-actuate-cms**: The generated `actuate.config.ts` uses the client-safe `storage: { provider: 'vercel-blob' | 's3' }` shorthand again (reverting the adapter-in-config change that broke the client build); uploads work out of the box via server-side auto-wiring.

## 0.71.1

### Patch Changes

- 1c81772: Make storage configuration honest. `platform.storage: { provider: 'vercel-blob' }` is a descriptive shorthand that never registered an adapter, so uploads silently failed with a misleading `BLOB_READ_WRITE_TOKEN` error. Now:
  - cms-core logs a clear boot-time warning when a remote `provider` shorthand is set but no adapter is registered, and `/media/upload` (used by media + appearance uploads) returns an error that names the real gap — a missing adapter, not a missing token.
  - `create-actuate-cms` generates `platform: vercel()` (a real adapter) instead of the inert shorthand, and the docs gain a dedicated Storage section.

- 9e0d82b: Stop persisting the lifecycle `status` inside document `data`. The lifecycle status is a top-level column, but a lifecycle-valued `status` passed in (or left behind by a migration/import script) was hoisted to the column **and** kept in the `data` JSON, where it confused the admin UI. `createDocument`/`updateDocument` now strip a lifecycle-valued `status` from `data` on write (and an edit cleans up already-polluted documents). Collection-defined `status` fields with non-lifecycle values — e.g. forms' `draft`/`active`/`archived` — are preserved.

## 0.71.0

### Minor Changes

- 9f5e5dc: Add the AI crawler activity panel (llms.txt measurement layer):
  - **`cms-core`** — new `AiCrawlerHit` model (`actuate_ai_crawler_hits`) + migration, aggregated per `(bot, surface)` so storage stays bounded. `detectAiBot()` classifies known AI user-agents (training vs retrieval) from the existing taxonomy; `llms.txt`, `llms-full.txt`, and `.md` requests from a known crawler are recorded best-effort. New `GET /ai-crawlers` (admin) returns aggregated activity, and an opt-in honeypot (`seo.llms.honeypot`) appends a trap link to `llms.txt` served by `GET /ai-crawlers/trap`.
  - **`cms-admin`** — new "AI crawler activity" panel under SEO → Technical showing per-bot hits, type, surface, and last-seen, plus totals. Frames the numbers honestly (visibility, not a traffic channel).
  - **`create-actuate-cms`** — scaffolded Prisma assets include the new migration so fresh projects get crawler tracking out of the box.

  Recording is always best-effort and gracefully no-ops on databases that predate the model.

## 0.70.0

### Minor Changes

- 0bcc567: Add per-page Markdown for the AI/agent layer:
  - **`GET /md?path=…`** (`@actuate-media/cms-core`) — returns a clean Markdown rendering of a single published page via `documentToMarkdown()`. Served with `X-Robots-Tag: noindex`; noindexed and `llmsInclude: NEVER` documents return `404` (the selection filter is the privacy boundary).
  - **Clean `/{path}.md` URLs** — `create-actuate-cms` middleware rewrites `/{path}.md` → `/api/cms/md`, so any page is fetchable as Markdown by appending `.md` (e.g. `/about.md`). The HTML page stays canonical and `.md` URLs are never in the sitemap.
  - **Shared path resolver** — extracted `matchPathToCollection()` so `/md` and `/resolve` agree on which document a public URL maps to (no logic drift).

## 0.69.0

### Minor Changes

- a889054: Add the advanced `llms.txt` layer — clean Markdown content export for AI agents:
  - **`documentToMarkdown()` serializer** (`@actuate-media/cms-core`) — renders a published document to clean Markdown. Rich-text/HTML slots are converted with Turndown; structured section content (and the page-builder block tree) is walked generically using an allow-list of prose/heading keys, so non-content data (URLs, ids, settings) never leaks and new section types render without per-type code.
  - **`/llms-full.txt`** — opt-in route (`seo.llms.fullEnabled`) serving the same curated, noindex, sitemap-excluded selection as `/llms.txt` with each page's Markdown inlined and delimited by its canonical URL. Bounded by `seo.llms.maxFullBytes` (default 2 MB) with clean truncation.
  - **Shared selection** — `/llms.txt` and `/llms-full.txt` now share one selector (`selectLlmsDocs` / `toLlmsPage`) so curation and the noindex privacy boundary can't drift.
  - **Scaffold** — `create-actuate-cms` proxies `/llms-full.txt` at the domain root (always generated; enabling is config-only).

## 0.68.0

### Minor Changes

- 4bf16c4: Harden the `/llms.txt` AI discovery file:
  - **Root proxy** — `create-actuate-cms` now scaffolds `app/llms.txt/route.ts`, exposing the file at the spec-required domain root (`/llms.txt`) in addition to `/api/cms/llms.txt`.
  - **noindex** — the route now sends `X-Robots-Tag: noindex` (and the scaffolded proxy preserves it) so the machine-facing index isn't itself indexed.
  - **Sanitization** — titles, descriptions, and the site summary are collapsed to a single line and stripped of markdown link-breaking and control/zero-width characters, then length-capped. Reduces corruption and instruction-smuggling risk from editor fields.
  - **Curation controls** — new `seo.llms` config: `disabled`, `eligibleCollections` (restrict AUTO selection to high-value types), `maxPerSection` (default 25), and `maxEntries` (default 50) so large archives can't flood the file.

- 47bed20: Add per-document and site-level curation for the `/llms.txt` AI discovery file:
  - **Per-doc curation** — `llmsInclude` (AUTO/ALWAYS/NEVER), `llmsDescription`, `llmsSection`, `llmsOptional`, and `llmsPriority` are read by the generator and round-tripped through the `/seo/content/:type/:id` API. ALWAYS pins an entry in regardless of the eligibility heuristic; NEVER excludes it.
  - **Spec correctness** — entries flagged optional are emitted under the reserved `## Optional` section; explicit section overrides and per-section priority sorting are honored; an optional reviewed intro block can follow the summary.
  - **Noindex exclusion** — noindexed documents are now excluded from `llms.txt` (the selection filter is the privacy boundary).
  - **Site config** — new `seo.llms.summary` (the reviewed blockquote, falling back to `defaultMetaDescription`) and `seo.llms.intro`.
  - **Admin UX** — the Edit SEO drawer gains an "AI discovery (llms.txt)" section: include mode, curated agent description with a character counter, section, and an optional toggle.

## 0.67.0

### Minor Changes

- 3ab0649: Add a nuanced AI-crawler policy to robots.txt generation. Training crawlers
  (GPTBot, ClaudeBot, Google-Extended, CCBot, …) and retrieval/answer-engine
  crawlers (OAI-SearchBot, PerplexityBot, ChatGPT-User, …) can now be allowed or
  blocked independently via `seo.robots.aiPolicy: { training, retrieval }`, so a
  site can stay out of training sets while remaining citable in AI answers. The
  legacy `seo.robots.blockAIBots` boolean still works (blocks both groups) and is
  used only when `aiPolicy` is not set. Curated 2026 bot lists are exported as
  `AI_TRAINING_BOTS` / `AI_RETRIEVAL_BOTS`.
- 5545bfd: Auto-create a 301 redirect when a published document's URL changes. When a
  document that is (and stays) published changes its `slug` or `data.path`, the
  old URL — already indexed and linked — is kept alive with a permanent redirect
  to the new URL. Planning is loop-safe and flattens chains: rules pointing at the
  old path are repointed to the new one, and a stale rule whose source is the
  now-live new path is removed. Runs post-commit and best-effort, so a redirect
  failure never rolls back the content save.
- 3f7fbf3: Add RSS 2.0 (`/feed.xml`) and JSON Feed 1.1 (`/feed.json`) content feeds.

  Both serve the newest published items across post-type collections (falling back
  to pages when there are no post collections), newest first, capped at 50, with
  canonical URLs and per-document `noindex` exclusion. Item descriptions prefer the
  GEO key takeaway, then the meta description, then the excerpt. Pure builders are
  exported as `buildRssFeed` / `buildJsonFeed`. New `create-actuate-cms` projects
  scaffold root `/feed.xml` + `/feed.json` proxy routes.

- bf00622: Add a GEO "key takeaway" field — a concise, citable 1-2 sentence page summary
  for generative answer engines.
  - Editable in the document SEO panel and the SEO Operations Center, each with an
    AI Generate button.
  - New `generateKeyTakeaway()` in plugin-ai and a `keyTakeaway` branch on
    `POST /ai/seo/generate-field`.
  - Rendered automatically as the Schema.org `abstract` on the page/article node,
    as a meta-description fallback, and as the preferred `llms.txt` description.
  - Stored as `data.keyTakeaway` (or `pageSettings.keyTakeaway`).

- f05c89b: `composePageMeta()` now emits a connected Schema.org `@graph` instead of a
  single flat node, so every public page (and the `/resolve` + `/seo/meta`
  responses that build on it) ships one traversable graph: WebSite +
  Organization (with `sameAs` and an optional SearchAction), the page node
  (WebPage or Article/BlogPosting), a BreadcrumbList derived from the URL path,
  FAQ `mainEntity`, a Person node for the author, and a linked specialized entity
  node for non-page types (LocalBusiness, Service, …). A doc-level
  `structuredData` override is still honored verbatim.

  This changes the shape of `ComposedPageMeta.jsonLd` from a flat object to
  `{ '@context', '@graph': [...] }`. Consumers that render it as a single
  `<script type="application/ld+json">` need no changes; code that read specific
  top-level keys off `jsonLd` should read them off the relevant `@graph` node.

- eb3786d: Metadata completeness for the public render path:
  - `composePageMeta()` now emits a Next.js `metadataBase` (from `seo.siteUrl`) so
    relative OG/canonical URLs resolve to absolute ones.
  - `og:image` falls back to the page's own hero / featured / cover image (string
    or `{ url }`) before the site default and the dynamic `/og.png`.
  - hreflang alternates are emitted in `<head>` (`<link rel="alternate" hreflang>`)
    and as `alternates.languages` in the Next metadata, including `x-default`.
    `/resolve` supplies them from the document's `localeGroupId` siblings.
  - `/llms.txt` now emits absolute, canonical URLs (honoring `data.path` and
    `urlPrefix`) and uses the configured `seo.siteName` / `seo.defaultMetaDescription`
    instead of a hardcoded empty origin and "Actuate CMS" name.

- 88161e8: Add a server-side publish validation gate. Publishing publicly-routed content
  (collections typed `page`/`post`) now runs a shared validator in the data layer
  — so the SDK, MCP tools, agents, and the API can't publish a page that's broken
  in ways the admin would have caught. Hard, content-agnostic invariants block a
  publish (a published page must have a title and a URL via slug or `data.path`);
  softer issues (e.g. missing meta description) are returned as warnings and never
  block. Configurable via `seo.publishValidation` (`block` default / `warn` /
  `off`). The documents API now maps these and other action errors to correct
  status codes (422 validation, 403 permission/access, 404 not-found) instead of a
  generic 500.
- 2f1f398: Add authoring guardrails to the page and post editors. The slug field now shows
  inline validation (malformed/reserved) using the same `validateSlug` rules the
  server publish gate enforces, and the client pre-publish check runs that same
  validation so failures appear in the validation summary instead of only as a
  post-publish toast. SEO title/description fields gain accessible character
  counters with quality bands (icon + label, not color alone) and smart
  placeholders that preview the title/excerpt fallback.

  `@actuate-media/cms-core/client` now re-exports the browser-safe slug helpers
  (`validateSlug`, `normalizeSlug`, `isValidSlug`, `isReservedSlug`, etc.).

- a3c5904: Add consistent content freshness signals. A shared `resolveContentDates` helper
  decides `datePublished`/`dateModified` for every public surface (honoring
  editorial date fields like `publishedDate`/`publishDate`/`date`), and both
  JSON-LD and Open Graph now use it so structured data and social tags agree.

  `llms.txt` entries now include an `Updated: YYYY-MM-DD` marker, JSON Feed items
  emit `date_modified` when newer than publication, the post-archive sitemap
  `<lastmod>` is derived from the newest post (instead of the current time), and
  the authenticated SEO preview endpoints now use `publishedAt` for publish time
  (matching production) instead of `createdAt`.

- ee53cfc: Unify image alt-text analysis behind a single decorative-aware analyzer
  (`analyzeImageAlts` / `countMissingAltImages` / `isDecorativeImgTag`) so the SEO
  score, audit engine, content-health scanner, and cron scan report consistent
  "missing alt" counts. Decorative images (`alt=""`, `role="presentation"`,
  `aria-hidden="true"`) are correctly excluded from the missing-alt count.

  The media picker now seeds an inserted image's alt text from the asset's curated
  library alt text instead of the file name.

- 218c5b0: Add canonical slug rules and server-side validation. A new `content/slug`
  module (`normalizeSlug`, `isValidSlug`, `isReservedSlug`, `validateSlug`,
  `SLUG_PATTERN`, `RESERVED_SLUGS`) is the single source of truth for slug format.
  The publish gate now rejects malformed (uppercase, spaces, underscores,
  leading/trailing/double hyphens) and reserved (`admin`, `api`, `sitemap.xml`, …)
  slugs server-side, and the admin `SlugField` normalizes input to the same rules.

  Unify SEO field-name reads so a value set in any editor surface is honored on
  the public page: canonical now accepts `canonicalUrl` (plugin-seo / SEO
  workspace) in addition to `canonical`; robots now accepts `noindex`/`nofollow`
  (SEO workspace) and `robotsNoindex`/`robotsNofollow` (plugin-seo) alongside
  `noIndex`/`noFollow`; the SEO analysis endpoint accepts `focusKeyword` alongside
  `focusKeyphrase`.

- df5d0de: SEO sitemap hardening: canonical URLs, noindex exclusion, accurate lastmod, hreflang, and IndexNow.

  **cms-core**
  - Sitemaps and the canonical `<link>` / `og:url` now resolve through a single shared `resolveDocumentUrl()` helper, so they can never disagree. It honours an explicit `data.path` (e.g. `/` for the homepage, `/about/story`), maps `home`/`index` to the site root, and otherwise uses `<urlPrefix>/<slug>`. Fixes homepages being listed as `/home` and any page whose `data.path` differed from `/{slug}`.
  - Noindexed documents (`noIndex`/`noindex`/`robotsPolicy: noindex*`, collection/site defaults, non-production guard) are now excluded from the sitemap so it never conflicts with a page's own robots meta tag.
  - The "Include posts in sitemap" crawl setting is now honoured by sitemap generation (both the index and the per-collection sitemap, which 404s when posts are excluded).
  - The "Index entire site" master switch is now enforced: when off, `robots.txt` emits a blanket `Disallow: /` for all crawlers, the sitemap (index + per-collection) serves empty, and publish-time search-engine/IndexNow pings are suppressed. This governs the public site only — CMS admin/API protection (auth + the admin's own `noindex` meta tag + the always-present `/admin` and `/api/` disallows) is unaffected.
  - Sitemap-index `<lastmod>` now reflects each collection's newest published change instead of "now", so crawlers can trust it.
  - `robots.txt` and the SEO settings now advertise the canonical public `/sitemap.xml` instead of the internal `/api/cms/sitemap.xml`.
  - hreflang `<xhtml:link rel="alternate">` annotations (incl. `x-default`) are emitted for translated documents when i18n is configured with more than one locale. No overhead for single-locale sites.
  - IndexNow support: when `INDEXNOW_KEY` is set, changed URLs are submitted to IndexNow (Bing, Yandex, …) on publish/update/delete, and the key is served at `/api/cms/indexnow-key.txt`. New exports: `resolveDocumentUrl`, `buildIndexNowSubmission`, `INDEXNOW_ENDPOINT`.

  **create-actuate-cms**
  - Scaffolded projects ship root `app/sitemap.xml/route.ts` and `app/robots.txt/route.ts` proxy routes so the canonical `/sitemap.xml` and `/robots.txt` URLs resolve out of the box.

## 0.66.0

### Minor Changes

- a6a17e9: Split login: serve the auth screens on their own non-obvious path while the admin app stays at `/admin`.
  - **cms-core**: new `admin.loginPath` config. `createCMSMiddleware` applies the same guards (IP allowlist, CMS_SECRET check, setup bypass) to the login mount and redirects expired sessions there. Emailed password-reset and invite links point at the login mount when configured.
  - **cms-admin**: new `LoginRoot` component mounts login (+ MFA), the first-run setup wizard, and forgot/reset-password on a separate route (e.g. `/securelogin`, with setup at `/securelogin/setup`), then hands off to the admin app after sign-in. `<AdminRoot loginPath>` makes the admin mount render a 404 lookalike for signed-out visitors instead of a login form — no redirect, so the login location is never leaked.
  - **create-actuate-cms**: new `--login-path` flag and prompt (default `/securelogin`). New projects scaffold the split-login layout by default: login/setup at the login path, admin app at the admin path, config + proxy wired accordingly. Pass `--login-path` equal to `--admin-path` for the classic inline login.

## 0.65.0

### Minor Changes

- 3e2daa4: Inline branded preview — see the draft through your real site without leaving the editor.
  - cms-admin: the page and post editors gain a "Site preview" toggle in the top bar that swaps the structure canvas for an iframe of the public site rendering the draft (same signed `?preview=<token>` contract as "View on site"). Unsaved edits are persisted first, the frame reloads after every save, and the existing viewport controls size the frame.
  - cms-core: `createSiteClient().resolveDocument(path, { previewToken })` forwards a preview token to `/resolve` so consumer routes can render drafts at their real URL.
  - create-actuate-cms: the scaffolded catch-all route now forwards `?preview=` to the resolver and shows a draft banner, so new projects support "View on site" and inline preview out of the box.

## 0.64.0

### Minor Changes

- 135d0c7: Taxonomy Phase B — admin facet filters and taxonomy columns on collection lists.
  - cms-core: `GET /collections/:slug` now accepts `filter.<field>=<value>` query params for fields declared in the collection config. Text/select fields filter by exact match; tag-shaped array fields filter by containment, matching both the canonical `[{ tag }]` row shape and legacy plain-string arrays. Undeclared fields are silently ignored. `listDocuments` gains a `dataFilters` option and `DataFieldFilter` is exported.
  - cms-admin: the collection list grows a facet filter bar (select fields use config options; free-text `category` and tag-shaped arrays load the collection's in-use values via the field-values API) plus taxonomy columns showing category/tag values per row, with a one-click "Clear filters" action.

## 0.63.0

### Minor Changes

- e3da3bc: Taxonomy Phase A — structured categories and tag autocomplete (UpChat platform feedback P1 + P2).

  **cms-core**
  - New `GET /collections/:slug/field-values?field=&q=` endpoint returning distinct in-use values for a declared text/select/array field (case-insensitive merge, most-used first, capped at 25). Powers admin tag autocomplete and category suggestions. Auth + collection read scope enforced; undeclared or long-form fields are rejected.
  - New `categoryField({ options })` field preset — renders as a structured `select` when options are provided, free text otherwise.
  - `blogPostCollection({ categoryOptions: [...] })` (and every collection preset with a category field) upgrades the free-text category to a select without hand-rolling field overrides. Plain strings are slugified into `{ label, value }` pairs; explicit `fields.category` overrides still win.

  **cms-admin**
  - New `TagInput` chip editor with autocomplete from the collection's existing vocabulary. Dedupes case-insensitively and resolves to the canonical casing already in use — no more `AI` / `ai` / `Artificial Intelligence` drift.
  - Post details modal is now field-config aware: a `select` category renders a dropdown (legacy free-text values stay selectable), and an `array` tags field renders the chip input. Free-text configs keep their current inputs.
  - `EditorPost` gains `tags`, round-tripped as canonical `[{ tag }]` rows; legacy plain-string arrays are still read.

## 0.62.0

### Minor Changes

- d6b21e7: Add Settings > Email: configure the transactional email provider from the admin UI.
  - **cms-admin**: new Email tab in Settings with a provider picker (Resend, SMTP.com, Amazon SES, generic SMTP), per-provider credential fields, masked stored secrets (last-4 only, write-only inputs), a "Send test email" button, and notices for environment/platform-managed configuration.
  - **cms-core**: new `__email_config__` store with API keys and SMTP passwords encrypted at rest (`CMS_ENCRYPTION_KEY`), `GET/PUT /email/settings` + `POST /email/settings/test` admin routes (test sends only to the signed-in admin's own address, rate-limited), and a DB-aware `resolveEmailSenderAsync()` used by invites, password resets, and form notifications. Resolution order: platform adapter → admin-configured settings → environment variables.
  - **plugin-email**: registers a provider factory with cms-core so admin-configured settings build real Resend/SMTP.com/SES/SMTP senders without env vars.

## 0.61.0

### Minor Changes

- 0c3a6eb: Posts UX improvements from external-consumer feedback:
  - New `admin.excludeFromPostsAggregate: true` collection flag keeps a post-shaped collection out of the admin's aggregate Posts area (cross-collection list, type tabs, New Post picker) while leaving its own admin list and API routes fully accessible. Enforced server-side in `GET /posts` and mirrored in the admin client.
  - `blogPostCollection()` now satisfies the admin Post Editor's full field contract out of the box: it gains `sections` (json), `category`, and `publishDate` (replacing `publishedDate` — update seeds/queries that referenced the old key), and `body` is no longer hard-required so section-driven posts can publish.
  - `create-actuate-cms` scaffolds now define `posts: blogPostCollection()` instead of an inline collection, so new projects always start with an editor-compatible posts schema.

## 0.60.0

### Minor Changes

- 854152d: Developer experience improvements from external-consumer feedback:
  - Every published package now ships its `CHANGELOG.md` in the npm tarball, and packages that were missing a `README.md` (cli, cms-admin, cms-core, import, platform-vercel, all plugins, sections-react) now have one — so release notes and usage docs are visible on npmjs.com and in `node_modules`.
  - New client-safe form validation entry point: `@actuate-media/cms-core/forms/client` exports `validateFormFieldClient()`, `validateFormValuesClient()`, visibility/conditional helpers, and the exact `FORM_EMAIL_RE` / `FORM_PHONE_RE` regexes the server enforces — zero Node dependencies, so public-site forms can mirror server rules without reimplementing them. The server validator now imports the same primitives, so client and server can never drift.

### Patch Changes

- bf5f857: `GET`/`PUT /post-templates/:type` now preserve sections whose type has no registered definition instead of silently dropping them. A template saved from the admin (which round-trips unmanaged sections) no longer loses seed/code-owned section types server-side; the derived `unmanaged` marker is stripped before persisting.
- Updated dependencies [854152d]
  - @actuate-media/realtime@0.1.1

## 0.59.1

### Patch Changes

- 15b3f45: Fix silent media upload data loss: `/media/upload` now returns a 500 (and creates no media record) when blob/adapter storage fails, instead of returning 201 with a dead `/api/cms/media/file/…` URL that nothing serves. Storage errors are logged with their cause (typically a missing `BLOB_READ_WRITE_TOKEN`). `/media/:id/optimize` likewise fails without touching the record when the optimized variant cannot be stored. Successful uploads now include `url` in the response so the admin can render previews immediately.

## 0.59.0

### Minor Changes

- f962e2d: New `defineSectionTypes()` authoring helper for custom section types. It is to `sections.types` what `defineConfig` is to the config file: a typed identity function with eager, fail-fast validation that throws one aggregated error naming every problem (blank/duplicate ids, built-in id collisions, unknown field types, select fields without options, repeaters without a sub-schema — with recursive sub-field checks). This replaces the silent-skip behavior consumers previously hit at runtime registration, where a malformed type simply never appeared in the Add Section gallery. Also exports `validateSectionTypeInputs()` (non-throwing issue list for tooling) and `SECTION_FIELD_TYPES` (runtime mirror of the field-type union). All three are available from both the package root (beside `defineConfig`) and the `/page-sections` subpath, along with the `CustomSectionTypeInput` and `SectionsConfig` types now re-exported from the root.

### Patch Changes

- 73a117a: Add API-handler contract tests covering the full REST surface (265 routes). A checked-in contract table pins every registered route (in first-match-wins registration order) to the exact status an unauthenticated request must receive, with a documented allowlist for the 19 deliberately-anonymous endpoints. The suite fails loudly when a route is dropped or renamed, when a protected route accidentally becomes public, when a handler 500s on anonymous traffic, or when an error response stops emitting the `{ error: string }` JSON envelope. No runtime changes.

## 0.58.0

### Minor Changes

- 363bb3e: Strongly-typed database surface (M4): replaced the pervasive `db: any` / `type Db = any` pattern with a structural `ActuateDB` client type — one typed delegate per shipped model plus the `$`-method surface — backed by generated row/enum types for all 34 models and 17 enums (`scripts/generate-db-types.mjs`, output committed at `src/db/model-types.ts`).
  - `guardedDb()`, `getDB<ActuateDB>()`, action transactions, auth/setup/forms/webhooks/cron/scheduling/SEO services now get delegate-name, method-name, and row-shape checking.
  - New public type exports: `ActuateDB`, `ModelDelegate`, `QueryArgs`.
  - Drift is caught two ways in CI: the e2e package compiles the real PrismaClient from the canonical schema and asserts it satisfies `ActuateDB` (`db-type-guard.ts`), and a freshness test verifies every schema model/enum appears in the generated types.
  - Typed rows surfaced two latent unguarded relation accesses (password-reset token user, webhook retry endpoint) which now fail closed.

  Query arguments deliberately remain loose (`QueryArgs = any`) — reproducing Prisma's generic argument inference by hand isn't maintainable, and anything narrower breaks real-client assignability.

## 0.57.1

### Patch Changes

- a5f8bc0: Internal refactor: split the 14,381-line `api/handlers.ts` monolith into 25 domain route modules under `api/routes/` (auth, documents, media, forms, SEO, users, webhooks, etc.), plus shared `api/route-helpers.ts` and `api/guarded-db.ts` modules. `registerCMSRoutes` is now a thin aggregator that preserves the original route registration order. No behavior or public API changes — `registerCMSRoutes`, `handleActuateAPI`, and all route paths are unchanged.

## 0.57.0

### Minor Changes

- 8e73cf5: Custom section types are now first-class. `actuate.config.ts` accepts `sections.types` as the canonical key (the previously-unreleased `sections.customTypes` remains as a deprecated back-compat alias, resolved via `resolveCustomSectionTypes`). The unscoped `GET /page-sections/types` endpoint now returns custom types alongside built-ins so admins can hydrate their registry from the API.

  Add an explicit `onUnknown` strategy to `coerceSections` (`'drop' | 'preserve' | 'error'`) plus `coerceSectionsWithReport` and `UnknownSectionTypeError`, so callers can warn or block on unknown section types instead of silently dropping them. The legacy `preserveUnknown` flag still maps to `'preserve'`.

  Add a generic `repeater` field type with a nested `fields` sub-schema and `minRows`/`maxRows`/`rowLabel` constraints; `stats` and `gallery` are now repeater presets. New helpers: `resolveRepeaterField`, `isRepeaterField`, `blankRepeaterRow`. Repeater rows (including required sub-fields and image alt text) are validated with row-scoped error/warning paths.

  Add a `pagePathField()` field preset for an explicit page URL path.

## 0.56.0

### Minor Changes

- 99565a1: Add config-declared custom section types and preserve externally-managed sections (Opal feedback).
  - New `sections.customTypes` on `ActuateCMSConfig` — declare custom section types (id, name, scope, fields, optional presentation/defaults) that are recognized, listed in discovery, and validated like built-ins. Registered at server boot in `handleActuateAPI`.
  - New registry API on `@actuate-media/cms-core/page-sections`: `registerSectionTypes`, `resetCustomSectionTypes`, `getAllSectionTypes`, plus `CustomSectionTypeInput` / `SectionsConfig` types. `getSectionType` / `getSectionTypesForScope` / `isKnownSectionType` / `sectionTypeLabel` now merge built-ins with custom types (built-ins always win an id collision).
  - `coerceSections(raw, { preserveUnknown: true })` keeps entries whose type isn't registered, flagged `unmanaged`, with content/settings verbatim and order intact, instead of dropping them. The page-section API (REST + MCP) now loads/persists with preservation so add/reorder/update/remove never drop seed/code-owned sections, and the derived `unmanaged` marker is never written back to storage.
  - `PageSection.sectionType` is now a `string` (was the built-in `SectionTypeId` union) and `PageSection` gains an optional `unmanaged` flag. `createSection` / `buildSection` accept any registered type id.

## 0.55.0

### Minor Changes

- 74662c0: Standardize `MediaUsage.fieldName` to `fieldPath` and harden Blob diagnostics.
  - Renames the `MediaUsage.fieldName` column to `fieldPath` to match the API layer, server actions, and `actuate doctor` expectations, with a new additive migration `0002_media_usage_field_path` (run `prisma migrate deploy`). Note: instances that already hand-renamed this column should mark 0002 as applied.
  - Runtime env diagnostics now escalate a partial Vercel Blob link (a store is connected via `BLOB_STORE_ID`/webhook key but `BLOB_READ_WRITE_TOKEN` is missing) from a warning to an error, since uploads fail in that state.
  - `createCMSMiddleware` now accepts `ActuateCMSConfig` directly (type-only), so consumers can pass their config without an `as unknown as` cast.
  - The optional MySQL/SQLite database adapters now mark their lazy `import()`s as bundler-ignored (`webpackIgnore`/`turbopackIgnore`), eliminating spurious "Module not found" build warnings for drivers a Postgres project never installs.

## 0.54.0

### Minor Changes

- 29f32ba: Deepen `GET /api/cms/collections` field introspection (roadmap WS-B4). The discovery endpoint now recursively summarizes `array` element subfields (with `minRows`/`maxRows`) and `blocks` variant subfields (keyed by block `slug`, with labels), in addition to the existing `relationTo`/`hasMany`/`options` hints — so an AI agent can plan a deep create/update without guessing nested shapes. The summary stays an allow-listed projection (no validators/hooks/access functions leak), is depth-capped defensively, and is exposed as a reusable `summarizeCollectionFields()` export (`FieldSummary`/`BlockSummary` types).

## 0.53.0

### Minor Changes

- aa4bd1a: Unify the page content model at write time (ADR 0002). Visual Page-Builder pages now compile their `_layout` tree to the canonical `data.sections[]` on every save, so every reader (SDK, MCP, search, content graph, `/resolve`) sees one contract instead of the lazy read-time bridge.
  - **cms-core**: `createDocument`/`updateDocument` derive `sections` from a `layout` envelope; the `/page-sections/*` write routes refuse a builder-owned page (one with `_layout`) with HTTP 409 to prevent silently-overwritten edits; the `/resolve` bridge is now a legacy fallback for un-migrated pages. New `migratePageBuilderSections()` export backfills legacy pages (idempotent).
  - **cms-admin**: the flat Page Editor opens a builder-owned page read-only with an "Open in Page Builder" action; `EditorPage` gains a `builderOwned` flag.
  - **cli**: new `actuate migrate:sections` command (with `--dry-run` / `--batch-size`) to backfill canonical sections for legacy page-builder documents.

## 0.52.0

### Minor Changes

- 01eef99: Security & reliability hardening from the production-readiness audit.
  - **Write-time HTML sanitization is now recursive (stored-XSS fix).** Previously only top-level `richText` fields were sanitized on write. HTML nested in `array`/`blocks` fields, the canonical `sections[]` rich-text slots, and the page-builder `_layout` tree was stored raw — a stored-XSS path reachable by any authenticated writer because the public renderer trusts stored HTML. All reachable author-HTML sinks are now sanitized before storage via the new `sanitize-document` module.
  - **AI analysis routes are rate-limited and input-capped.** `/ai/audit-document` and `/ai/suggest-internal-links` now enforce a per-user limit (60/hr) and a 50k-character input cap, and emit `ai_audit_document` / `ai_suggest_internal_links` audit-log events.
  - **`/media/upload` gains a dedicated strict limit** (20/hr/user) in addition to the global IP limit.
  - **Vision image fetches go through `safeFetch`** (SSRF-safe: host re-validation, DNS-rebinding defense, no redirects) with an 8 MB cap; `/ai/media-metadata` now passes real image bytes to the vision model instead of a bare URL.
  - **`/redirects/record-404` is now reachable from the public site.** It is CSRF-exempt (it only increments an unauthenticated 404 counter) and bounds its stored `path`/`referrer` to 2048 chars.
  - **`/public/globals/:slug` and `/public/navigations/:slug` no longer serve `ARCHIVED` documents.** They remain unauthenticated site chrome (not PUBLISHED-gated), but an explicitly retired record is excluded.
  - A runtime warning is logged when a collection's `fields` is defined as an array instead of an object map (silent data loss otherwise).

## 0.51.2

### Patch Changes

- aeee3a4: Docs: correct the migration-baseline upgrade note shipped in 0.51.1.

  The previous note told deployments "previously migrated with `prisma migrate deploy`" to run `prisma migrate resolve --applied 0001_init`. That guidance is wrong for exactly that audience: such a database already has a `0001_init` row in `_prisma_migrations`, so `resolve --applied 0001_init` errors with `P3008: migration is already recorded as applied`. Those deployments need **no action** — `migrate deploy` applies only _pending_ migrations and does not re-validate the squashed baseline, so it is a no-op.

  `prisma migrate resolve --applied 0001_init` is only needed to baseline a database that already has the `actuate_*` tables but has **no** migration history (e.g. one provisioned via `db push`, or restored from a dump) before adopting `migrate deploy`. Run `prisma migrate status` first if unsure.

## 0.51.1

### Patch Changes

- f88a406: Fix document-create/update hijacking a collection's own `status` field, and reconcile the migration history into a clean baseline.
  - **Forms (and any collection with a `status` field) can be created again.** `createDocument`/`updateDocument` treated a `data.status` value as the document lifecycle status. Collections such as Forms store their own `status` (`draft`/`active`/`archived`) inside `data`, so creating a form threw `Cannot create a document with status "draft"` and activating one would have written an invalid enum value. We now only hoist `status` onto the document column when it is an actual `DocumentStatus`; any other value stays in `data`. Regression tests added in `actions/document-crud.test.ts` and exercised end-to-end in the new `@actuate-media/e2e` harness.
  - **Migration history baseline.** The committed migrations did not apply from an empty database (`0003` indexed `actuate_documents.title` before any migration created it) and were ~223 columns / several tables behind `schema.prisma`, so `prisma migrate deploy` on a fresh DB crashed. Migrations `0001`–`0012` are squashed into a single `0001_init` baseline generated from the current schema (plus the raw full-text-search GIN index). A PGlite-based drift guard now asserts the migrations apply from empty and reproduce the schema.

    **Action required for existing deployments** that were previously migrated with `prisma migrate deploy`: run `prisma migrate resolve --applied 0001_init` once so Prisma records the baseline as already applied instead of attempting to re-create existing tables. Fresh installs need no action.

  - **Config footgun guard.** `setActuateConfig` now logs a one-time warning when a collection declares `fields` as an array instead of the object-map shape cms-core expects (an array silently drops every custom field on write).

## 0.51.0

### Minor Changes

- a413616: Add MCP relationship-resolver tools (WS-C C2) so agents stop guessing document IDs.
  - **cms-core (additive, backward-compatible):**
    - `listDocuments` / `GET /collections/:slug` now accept an exact `slug` filter. Because `slug` is `@@unique([collection, slug])`, this is the canonical single-query slug→id lookup.
    - The `GET /collections` discovery endpoint now surfaces `relationTo` (for relationship fields), `hasMany`, and select `options` in each field summary, so an agent can learn where a relationship field points. (Overlaps the WS-B4 schema-surfacing work.)
  - **mcp-server:** three new tools — `get_relationship_fields` (list relationship/media/select fields + their `relationTo`), `resolve_relationship` (exact slug → `{ id, slug, title, status }`), and `list_relationship_targets` (browse candidate documents when the slug is unknown). README updated with a "Resolving relationships" section.

- 204da7a: WS-E (E3): media resolution + responsive images in the public render path.
  - **sections-react:** new pluggable `renderImage` `RenderOptions` hook (with
    exported `RenderImage`/`RenderImageProps` types and a `defaultRenderImage`).
    The default emits a lazy, `decoding="async"` `<img>` and forwards intrinsic
    `width`/`height` when known; a Next.js consumer can pass a `next/image`-backed
    renderer for responsive `srcset` + optimization. The `feature`, `gallery`, and
    post-header images now route through it (`<PostHeader>` gains an optional
    `options` prop). The package stays React-only — no `next` dependency.
  - **cms-core:** the `/resolve` endpoint now enriches section images with
    intrinsic `width`/`height` and library `altText`, matched from `actuate_media`
    by stored URL (`storageKey`/`publicUrl`). Additive and best-effort — the
    stored document is never mutated and a lookup failure never breaks resolve.
    `feature` sections gain `imageWidth`/`imageHeight` (+ backfilled `imageAlt`);
    `gallery` rows gain `width`/`height` (+ backfilled `alt`).
  - **create-actuate-cms:** the generated catch-all page renders CMS images via a
    `next/image`-backed `renderImage` (falling back to a fluid `<img>` when the
    CMS doesn't know dimensions), and `next.config` now allow-lists the storage
    host under `images.remotePatterns` so optimization works out of the box.

- 2f18c56: WS-E (E2): public form rendering — a built site can now render and submit a CMS form end to end.
  - **sections-react:** new `<ActuateForm>` Client Component fetches the public form
    schema (`GET /api/cms/public/forms/:slug`), renders the fields (text/email/
    url/tel/number/date, textarea, select, checkbox, radio), and submits to the
    schema's `submitPath`. Spam protection is wired automatically: a hidden
    honeypot (keyed from `spam.honeypotFieldKey`), an `elapsedMs` "time to submit"
    timer, and an optional consumer-supplied `captchaToken`. Pre-fetch a schema via
    the `form` prop to skip the round-trip (SSR/streaming). Adds a `baseUrl`
    `RenderOptions` field (defaults to same-origin). Exposes pure, tested helpers
    (`coercePublicForm`, `buildSubmitBody`, `fieldControl`, `firstMissingRequired`).
  - **cms-core:** new canonical `form` section type. Set `content.formSlug` (plus
    optional heading/intro) on any page or post and the matching renderer embeds
    the form. The registry-drift guard now covers it (renderer ↔ type).
  - **cms-admin:** the form Embed panel now shows the real public endpoints
    (`/api/cms/public/forms/:slug` + `/submit`) and a copy-paste `<ActuateForm>`
    snippet, replacing the previously-advertised `/api/forms/*`, iframe, and
    `embed.js` routes that were never implemented. Adds a `mail` section icon.

- e2e0bd7: WS-E (E4): public navigation endpoint + navigation rendering in the scaffold.
  - **cms-core:** new unauthenticated `GET /api/cms/public/navigations/:slug` that
    returns a clean `{ name, slug, location, items }` tree. Like globals it is site
    chrome — not status-gated — and safe when the navigation plugin isn't installed
    (no match → 404). A shared, pure `coercePublicNavItems` helper sanitizes the
    open `data.items` JSON: drops unlabeled nodes, strips internal `id`s, caps
    recursion depth, and only passes through `_blank`/`_self` targets. Both
    `createSiteClient` and `createServerSiteClient` gain `getNavigation(slug)`
    returning `PublicNavigation | null`, and `PublicNavigation` / `PublicNavItem`
    are now exported types.
  - **sections-react:** new presentational `<SiteNav>` — an accessible `<nav>`
    landmark whose submenus use native `<details>`/`<summary>` disclosure
    (keyboard-operable, no client JS). New-tab links get
    `rel="noopener noreferrer"`; an empty menu renders nothing. Exports `SiteNav`
    plus `SiteNavProps`/`SiteNavItem`.
  - **create-actuate-cms:** `lib/cms.ts` re-exports `getNavigation`, and the
    generated site layout fetches the `main` menu and renders a header with the
    site name + `<SiteNav>`, degrading gracefully to just the name when no menu
    exists yet.

## 0.50.0

### Minor Changes

- d7a144c: Add a page-builder → canonical sections bridge so existing `_layout` documents aren't stranded (ADR 0001, WS-A5).
  - New `layoutTreeToSections()` export on the `@actuate-media/cms-core/page-sections` subpath converts a page-builder `_layout` `BlockNode` tree into a best-effort canonical `PageSection[]`: it flattens the page → section → container/row/column → block nesting in document order, maps each block's `blockType` to the closest canonical section type, degrades unknown/complex blocks to a `text` section that surfaces their content, and propagates the enclosing builder section's anchor (`htmlId`) and hidden visibility. It is pure, DB-free, and never throws (`savedSectionRef` nodes are skipped — they require a separate lookup).
  - The `/resolve` endpoint now applies the bridge at read time: when a page/post has a `_layout` tree but no flat `sections`, the resolved response is enriched with derived sections so the content renders through `@actuate-media/sections-react`'s `<Sections>`. This is non-destructive — the stored document is never mutated, and a document's own `sections` always take precedence.

## 0.49.0

### Minor Changes

- 9ba332c: Converge the scaffolder onto the canonical page-content contract (ADR 0001, WS-A4).
  - **create-actuate-cms**: New projects now use the canonical `sections` (JSON `PageSection[]`) field for both `pages` and `posts`, and the generated public catch-all renders `data.sections` with `<Sections>` from `@actuate-media/sections-react` — passing cms-core's allow-list `sanitizeHtml` so author HTML (e.g. the article-body section) is sanitized before the DOM, and rendering a `<PostHeader>` for post-type documents. The deprecated Model C path is retired: the `content[]`/`BLOCK_MAP` `BlockRenderer`, `HeroBlock`, `CTABlock`, `TextBlock`, `ImageTextBlock`, `SiteHeader`, and `SiteFooter` generators (and the `blocks`-gated `layout`/headers/footers config) are removed. Generated apps now depend on `@actuate-media/sections-react` and emit the matching Tailwind `@source` directive.
  - **@actuate-media/cms-core**: `ResolveResult` (the typed `createSiteClient().resolveDocument()` return) now exposes the optional `meta`, `metadata`, `jsonLd`, `jsonLdHtml`, `template`, `post`, and `preview` fields the `/resolve` endpoint already returns, plus the new `ResolvePostAuthor`, `ResolveRelatedPost`, `ResolvePostContext`, and `ResolvePreview` types — so consumers can render the full post experience (header, author, related posts, SEO metadata) without re-declaring the shape.

## 0.48.0

### Minor Changes

- ae1af8f: Authoring correctness fixes for the AI site-building workflow (roadmap WS-B/E quick wins).
  - **cms-core**: `createDocument` now honors a requested `status` (`DRAFT` or
    `PUBLISHED`) instead of always forcing `DRAFT`. Publishing on create is gated
    to EDITOR/ADMIN, stamps `publishedAt`, and emits the `document.published`
    webhook + sitemap notify. This fixes `POST /page-builder/create?publish=true`
    (and `status: 'PUBLISHED'`), which previously created a draft silently.
    Statuses other than `DRAFT`/`PUBLISHED` on create are rejected with guidance
    to schedule/archive via update.
  - **client**: `listMedia()` now returns the canonical
    `{ docs, total, page, pageSize, totalPages }` (`MediaList`) envelope. The
    server returns the asset array under `items`/`data` (never `docs`), so the
    previous typing made `listMedia().docs` always `undefined`. Adds exported
    `MediaAsset` and `MediaList` types and an optional `init` (signal/cache) arg.
  - **mcp-server**: the `list_media` tool now normalizes the same `items`→`docs`
    shape so its output matches `list_documents`.
  - **create-actuate-cms**: generated `TextBlock`/`ImageTextBlock` now sanitize
    author-supplied HTML with `sanitizeHtml` before `dangerouslySetInnerHTML`
    (was an XSS vector on hostile content).

## 0.47.0

### Minor Changes

- 7d11b8d: Production-ready Settings → Updates tab with real release intelligence.
  - **cms-core**: Fix a critical permission bug where `GET/PUT /updates/config` and
    `POST /updates/apply` rejected real admins (they compared `session.role` to the
    lowercase `'admin'` while roles are stored uppercase). They now use
    `requireAdminScope`, so OWNER/ADMIN sessions (and admin-scoped API keys) can
    manage updates. `GET /updates/check` no longer leaks the GitHub repo to
    non-admins and reports a `canManage` flag. Update checks now run through a new
    hybrid resolver `getUpdateStatus()` that queries the Actuate update server
    first (rich, real changelog/severity/migration/breaking-change data derived
    from the published CHANGELOG) and transparently falls back to a direct npm
    registry check. New exports: `getUpdateStatus`, `checkForUpdatesViaServer`, and
    the `ChangeType` / `ChangelogItem` / `PluginCompatibilityInfo` types; richer
    `VersionInfo`. `fetchChangelog()` now targets the real `/api/changelog`
    endpoint (overridable via `ACTUATE_UPDATE_SERVER_URL`).
  - **cms-admin**: Rebuild the Updates tab as a dedicated `UpdatesTab` + `useUpdates`
    hook (extracted from `Settings.tsx`). The GitHub integration now uses the shared
    `SettingsControlRow` + sticky `SettingsSaveBar` dirty-state pattern, and the tab
    surfaces real release notes (categorized changelog), a security-update badge,
    "migrations required" and breaking-change callouts, and plugin compatibility.
    Write actions are permission-aware (hidden for non-admins).
  - **cli**: `update-check` / `upgrade` now resolve the update-server base URL from
    `ACTUATE_UPDATE_SERVER_URL` (defaulting to the production host), so the
    `/api/versions` endpoint is configurable for self-hosted update servers.

## 0.46.0

### Minor Changes

- 4e6ead2: Add Brand Voice. Admins can define a reusable brand voice (tone, audience, guidelines, and
  sample copy) in Settings > AI. When enabled, the governed AI runtime injects it into the system
  prompt of content-generation features — co-author and SEO titles/descriptions — so every AI
  surface writes in one consistent, on-brand voice.
  - `cms-core`: new `BrandVoiceProfile` type on `AISettings`, a `brand-voice` module
    (`buildBrandVoiceSystemPrompt`, `applyBrandVoice`, `getActiveBrandVoice`, `normalizeBrandVoice`),
    a `useBrandVoice` option on `generateTextForFeature`, and `PUT /ai/settings` persistence
    (length-clamped, no secrets).
  - `cms-admin`: a Brand Voice card in the AI settings tab wired through `useAiSettings`.

## 0.45.0

### Minor Changes

- df17a3f: Unify AI execution behind a governed runtime. Provider adapters now support text
  generation (OpenAI/xAI/custom via the OpenAI-compatible chat API, Anthropic via
  the messages API), and a new `ai/runtime` resolves a feature to its connection +
  routed model, enforces the feature toggle and monthly token budget, resolves the
  API key from env or the encrypted store, and records real token usage for the
  usage meter. The `/ai/coauthor` endpoint now runs through this governed path
  first — using the provider and per-feature model configured in Settings > AI —
  and falls back to the legacy `@actuate-media/plugin-ai` global provider only when
  no governed provider is configured. Disabled features return 403 and budget
  overruns return 429.

  New exports: `generateTextForFeature`, `resolveFeatureRuntime`,
  `isAiFeatureEnabled`, and `adapterGenerateText`.

- 11b4dfa: Route the SEO assistant endpoints through the governed AI runtime. `/ai/seo/generate-field`
  (titles, meta descriptions, focus keyword), `/ai/seo/explain-issue`, `/ai/seo/generate-schema`,
  and `/ai/seo/summarize` now generate via the provider + per-feature model configured in
  Settings > AI (gated by the `ai.features.seoMetaGeneration` toggle, enforcing the monthly token
  budget and recording real usage). They fall back to the legacy `@actuate-media/plugin-ai`
  provider only when no governed provider is configured. A disabled feature returns 403, a budget
  overrun 429, and a provider failure 502; when nothing is configured the response is 503 with a
  "Connect a provider in Settings > AI" message.

## 0.44.0

### Minor Changes

- fcede90: Settings > AI: per-feature model routing. Each AI feature can now run on a
  specific model (e.g. a cheap model for alt-text, a frontier model for content
  writing) or inherit the default. Adds `resolveFeatureModel` /
  `resolveAllFeatureModels` to cms-core as the authoritative feature→model
  resolver (capability-aware, with capability-typed and fallback defaults), and
  extends `PUT /ai/settings` to persist + validate per-feature `modelId`/
  `providerId` overrides. The admin AI Features card gains a per-feature model
  picker that filters to capability-compatible models and warns when a routed
  model is missing a required capability (e.g. a text-only model on a vision
  feature).

## 0.43.0

### Minor Changes

- 89eebe0: Add Settings > AI (Phases 1-2): DB-backed provider connections + dynamic model catalog.

  **cms-core**
  - New `src/ai` module: provider connections (OpenAI, Anthropic, xAI, custom OpenAI-compatible), a normalized model catalog, feature flags, and budget settings, all stored in an `__ai_config__` config document (no Prisma migration, mirroring the SEO config-store).
  - Provider API keys are encrypted at rest with `encryptSecret` and never returned to the client — responses expose only a masked `apiKeyLast4` and a source flag. When a provider's env var (`OPENAI_API_KEY` / `ANTHROPIC_API_KEY` / `XAI_API_KEY`) is set, that key wins and is surfaced as env-managed/read-only.
  - SSRF-safe adapters: built-in providers use fixed hostnames; custom base URLs are validated and DNS-resolved via the existing webhook guards.
  - Dynamic catalog merges live `/v1/models` listings with a curated capability/pricing map, with a curated fallback when live listing is unavailable and `unavailable` marking for configured-but-missing models.
  - Monthly usage summary aggregated from existing audit-log token totals (real counts; 0 until per-run metering lands).
  - New ADMIN-gated, audited routes: `GET/PUT /ai/settings`, `POST/PUT/DELETE /ai/providers(/:id)`, `POST /ai/providers/:id/test`, `POST /ai/providers/:id/sync-models`, `GET /ai/models`, `GET /ai/usage/summary`.

  **cms-admin**
  - Replaced the read-only AI status panel with a full `AISettingsTab`: AI Assistant card (default provider + model selector with capability badges and Refresh), provider connection management (add/test/disconnect, masked or env-managed key handling with confirm dialogs), global AI feature toggles, and a real monthly usage meter with a budget limit.
  - Feature enforcement across modules and per-run usage metering are deferred to later phases; toggles and budget persist now.

## 0.42.0

### Minor Changes

- 8dfeb94: Add a **Settings > SEO** tab for site-wide SEO defaults, backed by the shared SEO configuration service (no duplicate state with `/seo` or Settings > General).
  - **cms-core**: extend `SiteSEOConfig` with `titleTemplate`, `defaultMetaDescription`, `defaultSocialImageAssetId`, and a `verification` block; add title-template token validation/application, search-engine verification meta-tag rendering, and wire all three into the public page-meta renderer. New `notifySitemapOnPublish` service performs production-gated, SSRF-safe sitemap regeneration + Google/Bing pings, fired as a best-effort effect when public content is published, updated, unpublished, or deleted. Cache invalidation runs on every change while the timestamp write + outbound ping are debounced (default 60s) so bursts of publishes don't hammer the shared `__seo_config__` row or spam search engines. `PUT /seo/config` and `PUT /seo/crawl-settings` now validate new fields and write field-level audit logs (verification token values redacted).
  - **cms-admin**: new `SeoSettingsTab` (Meta Defaults with SERP/social preview, Search Engine Verification, Sitemap Defaults, full-workspace callout, Advanced disclosure) driven by a `useSeoSettings` hook that reads/writes the shared `/seo/config` and `/seo/crawl-settings` endpoints with dirty tracking, validation, permission-aware read-only states, and dangerous-change confirmation. `MediaPickerModal` gains an `onSelectItem` callback so the default social image stores its asset id.

## 0.41.0

### Minor Changes

- 7336b5f: Add content-ownership transfer to Settings > Users.

  cms-core: new `transferContentOwnership` / `countOwnedContent` helpers reassign
  a member's authored documents, uploaded media, and created templates to another
  active user (immutable version/audit history is never touched). New admin-only,
  audited `POST /users/:id/transfer-content` endpoint validates the recipient
  (must exist and be active) and applies the same Owner/last-Owner guards as the
  other per-user actions. `contentOwnedCount` in the user detail now reflects all
  three content types, not just documents.

  cms-admin: the user detail drawer Overview now offers "Transfer content" with an
  active-member picker before revoking access. Auxiliary dialogs (transfer,
  confirm, reauth) now render inside the Drawer's Radix focus scope so they are
  keyboard-reachable and no longer marked `aria-hidden` behind the drawer.

## 0.40.0

### Minor Changes

- 951963a: Build the Settings > Users access-control system: real invites, roles, seats, and access review.

  **@actuate-media/cms-core**
  - Schema: add `OWNER` and `VIEWER` to the `UserRole` enum, a new `InviteStatus` enum, a new `Invite` model (`actuate_invites`), and additive user-lifecycle columns (`suspendedAt`, `lockedAt`, `forcePasswordReset`, `mfaRequired`, `failedLoginCount`, `lastActiveAt`, `invitedById`, `acceptedInviteAt`). Two hand-authored migrations (`0011_user_roles_owner_viewer`, `0012_invites_and_user_lifecycle`) — enum values are added in their own migration so Postgres can use them as defaults afterward. All user columns are nullable/defaulted so existing rows migrate cleanly.
  - Add a `roles` module (also exported as the `@actuate-media/cms-core/roles` subpath): display mapping (`OWNER/ADMIN/EDITOR/VIEWER` plus legacy `AUTHOR→Editor`, `CLIENT→Viewer`), assignable roles, privilege ranking, and per-role permission derivation.
  - Add an `Invite` lifecycle service (`createInvite`, `listInvites`, `resendInvite`, `cancelInvite`, `updateInviteRole`, `extendInvite`, `expireStaleInvites`, `markInviteAcceptedForEmail`). Invite tokens are hashed at rest, single-use, and expire (7/14/30 days). Completing the password-reset flow for an invited email now marks the invite `ACCEPTED` atomically.
  - Add a `team` module (`listTeamMembers`, `getUserStats`, `computeAccessReview`) that derives seat usage and access-review recommendations (inactive 90d+, external domain, admin/owner without MFA, stale invite) from real user/security signals.
  - New admin endpoints: enriched `GET /users` (search/role/status filters), `GET /users/stats`, `PATCH /users/:id/role`, hardened `DELETE /users/:id` (revoke = suspend, last-Owner protection), `GET /invites` + `POST /invites/:id/resend|cancel` + `PATCH /invites/:id`, and `GET /access-review` + `POST /access-review/:id/dismiss`. Role assignment/removal of Owner is Owner-only, self-escalation is blocked, role downgrades revoke sessions, and seat limits are enforced. All sensitive actions are audit-logged.

  **@actuate-media/cms-admin**
  - Rebuild the Settings > Users tab to match the design: dynamic summary cards (members, pending invites, admins, seats used), an actionable access-review banner, a combined team table (active members + pending invites) with role-filter pills and search, per-row role dropdowns and revoke/resend/cancel actions, and an invite modal with role radio cards and advanced options (custom message, expiry). Includes loading, empty, filtered-empty, and error states; destructive and Owner-assignment actions require confirmation.

  **create-actuate-cms**
  - Sync the scaffold's Prisma schema and migrations with the new Users-management schema so newly created projects start with the `Invite` model, new roles, and user-lifecycle columns.

- 951963a: Settings > Users phase 2: per-user detail, sessions, activity, and security actions.

  **@actuate-media/cms-core**
  - Add a `user-admin` read-model module (`getUserDetail`, `listUserSessions`, `listUserActivity`, `getUserPermissionView`, `parseUserAgent`) that shapes a single user's overview, security posture (MFA / password / lock / SSO-only), sessions (with device parsing + "this device" marker), audit activity (events where the user is actor or target), and derived permissions.
  - New admin-only, audited per-user endpoints: `GET /users/:id`, `GET /users/:id/sessions`, `DELETE /users/:id/sessions/:sessionId`, `POST /users/:id/sessions/revoke-all`, `GET /users/:id/activity`, `GET /users/:id/permissions`, `POST /users/:id/password-reset`, `POST /users/:id/force-password-reset`, `POST /users/:id/mfa-reset`, `POST /users/:id/require-mfa`, `POST /users/:id/suspend`, `POST /users/:id/reactivate`, `POST /users/:id/lock`, `POST /users/:id/unlock`. Owner actions are Owner-only, self-actions and last-Owner removal are blocked, MFA reset and Admin/Owner password resets require password reauthentication, and session-affecting actions revoke sessions.
  - Login now rejects suspended (`suspendedAt`) and locked (`lockedAt`) accounts so an admin Suspend/Lock takes effect on the next sign-in attempt — distinct from full deactivation (`isActive=false`), which preserves the account's content and history.

  **@actuate-media/cms-admin**
  - Add the User detail drawer opened from a Team members row (and the row "View details" action): Overview, Security, Sessions, Activity, and Permissions tabs. Security actions (send/force password reset, reset/require MFA, lock/unlock, suspend/reactivate, revoke one/all sessions) are wired to the new endpoints with confirmation dialogs and an inline re-authentication prompt that retries with the `x-reauth-password` header when the server requires it. Includes loading, empty, and error states across all tabs.

## 0.39.0

### Minor Changes

- f043e18: Fix day-one auth dead-ends and wire up transactional email + user invites.

  **@actuate-media/cms-core**
  - Add a transactional email sender registry (`setEmailSender`, `getRegisteredEmailSender`, `resolveEmailSender`). Password-reset and form-notification email now resolve a sender via this registry, so apps that configure email purely through `emailPlugin()` actually send (previously only `config.platform.email` was consulted, so reset/notification mail silently never sent).
  - Password reset links are now built from the configured admin path instead of a hardcoded `/admin`, so resets work on apps mounted elsewhere.
  - Add an admin-only `POST /users/invite` endpoint plus `createUserInvite` / `normalizeInviteRole` / `VALID_INVITE_ROLES`. Invites provision the user with an unusable password and a single-use, 7-day token, then email a "set your password" link (reusing the existing `/reset-password` page). When no email sender is configured the response returns the link so an admin can share it manually. Rate-limited per admin.

  **@actuate-media/cms-admin**
  - `AdminRoot` gains an `onLogout` prop. The account-menu "Logout" item now revokes the server session (`POST /auth/logout`), clears local session state, and returns to the login screen — previously it navigated to a non-existent `/logout` route (404) and left the session valid.
  - The "Invite User" dialog is now a real flow backed by `POST /users/invite` (loading state, error handling, valid roles, optional name) instead of a fake success toast.

  **create-actuate-cms**
  - The scaffolded admin page now wires `onVerifyTotp` (so MFA users can complete the second-factor step) and `onLogout`, matching the dev app.

  **@actuate-media/plugin-email**
  - `emailPlugin()` now registers its resolved provider with cms-core's email sender registry, so configuring email via the plugin is sufficient for password-reset, invite, and form-notification email.

- 05537a8: Replace the decorative AI settings tab with an honest, read-only status panel.

  The previous AI tab shipped a fake API-key field (the key was never saved), a no-op "Verify" button, a misleading "encrypted at rest" claim, hardcoded zero usage metrics, and nine feature toggles that were read by nothing. None of it configured AI — that happens server-side via `@actuate-media/plugin-ai` and environment variables.
  - `cms-core`: new admin-only `GET /ai/status` endpoint reporting `{ installed, configured, provider }`. It detects whether the AI plugin is installed and a provider is configured, and hints at which provider env var is present — never exposing key values.
  - `cms-admin`: the AI tab now renders a truthful status panel (active / installed-but-unconfigured / not-installed) plus setup guidance, and no longer pretends to store credentials.

- 456e5f2: Fix client onboarding so a freshly scaffolded project actually has a working database.

  **create-actuate-cms**
  - Scaffolded projects now ship the canonical Prisma schema (all `actuate_*` models with `@@map`) and the full migration history, synced from `@actuate-media/cms-core` via `scripts/sync-prisma-assets.ts` and guarded by a drift test. Previously the scaffolder hand-maintained a divergent schema (14 of 33 models, no `@@map`) and shipped no migrations, so `prisma migrate deploy` in the build created no tables and core features (full-text search, i18n, API metrics, API keys, webhooks, notifications, etc.) were broken.
  - The generated `prisma.config.ts` now warns when migrations would run against the pooled `DATABASE_URL` because `DIRECT_DATABASE_URL` is unset.
  - The generated admin page now shows an actionable "run migrations" / backend-unreachable message instead of silently dropping to the login screen when the database has no tables or `/setup/status` fails.
  - Next-steps + deployment docs now use `prisma migrate deploy` (applies the bundled migrations).

  **@actuate-media/cms-core**
  - `checkSetupRequired` now reports `migrationsRequired: true` (new `SetupStatus` field) when the database is reachable but the tables don't exist yet, and `POST /setup/create-admin` returns an actionable 503 in that case. New exported helper `isSchemaMissingError`.
  - The admin middleware now returns an actionable HTML page (with remediation steps) when `CMS_SECRET` is missing, instead of a bare `500`.

- 90e80b6: Fix `plugin-email` test timeouts in CI by importing the email-sender registry from a lightweight subpath.

  `emailPlugin()` previously imported `setEmailSender` from the full `@actuate-media/cms-core` barrel (handlers, middleware, the whole graph). Because the plugin's tests re-import the module on every `vi.resetModules()`, transforming that barrel repeatedly blew past the 5s test timeout on cold CI runners.
  - `cms-core`: adds a new `@actuate-media/cms-core/email-sender` subpath export exposing `setEmailSender` / `getRegisteredEmailSender` / `resolveEmailSender` (the registry only depends on the config runtime).
  - `plugin-email`: imports `setEmailSender` from that subpath instead of the barrel. No runtime barrel import remains.

## 0.38.0

### Minor Changes

- 008544f: Complete the MFA loop: account enrollment, login challenge, and backup-code recovery.

  Previously the TOTP backend existed but had no user-facing plumbing — there was no
  way to enroll from the admin, the login screen couldn't complete a second-factor
  challenge, and backup codes were generated but never consumed anywhere (a lost
  authenticator meant permanent lockout). The "Require MFA" policy was therefore
  impossible to satisfy.

  **cms-core**
  - `POST /auth/totp/login` now accepts a one-time backup code as well as a TOTP code.
    Matches are constant-time and single-use (the code is removed and remaining codes
    re-encrypted); `login_success` audit records whether a backup code was used.
  - New pure helpers `normalizeBackupCode` / `findBackupCodeMatch` in `auth/totp`.
  - `GET /auth/me` now returns `totpEnabled` so the admin can show enrollment state.

  **cms-admin**
  - New `/profile` view (fixes the previously dead account-menu link) with an
    **Account security** card: password reauth → QR code + manual key + downloadable
    one-time backup codes → verify, plus a reauth-gated disable flow.
  - The login screen now renders a **two-factor challenge** step (authenticator code
    with a backup-code fallback). `AdminRoot`/`Login` gain an `onVerifyTotp` prop and
    `onLogin` may now return `{ requiresTOTP, mfaPendingToken }`.
  - The Security tab's "MFA not set up" warning now links to the enrollment flow.
  - Adds `qrcode.react` for QR rendering.

## 0.37.0

### Minor Changes

- 06940a1: Add the Settings → Security tab as a real Security Center.

  **cms-core**
  - New `@actuate-media/cms-core/security-center` subpath export with pure, client-safe
    helpers: `computeSecurityStatus`, `parseSecuritySettings`, `validateSecuritySettings`,
    `validateIpRange`, `mfaEnableLockoutError`, `summarizeUsers`, `summarizeApiKeys`, and types.
  - New API routes: `GET /security` (computed posture + cross-module summaries +
    secret/encryption verdicts, never secret values), `PUT /security` (persists enforced
    MFA + session policy with server-side lockout-safe MFA enabling, audited as
    `security_settings_changed`), and `POST /security/sessions/revoke-all` (revokes the
    caller's other sessions, audited).
  - Concurrent-session enforcement now honors the stored `security.session.maxConcurrentSessions`
    policy (config/default precedence preserved when unset; `0` = unlimited).
  - Login responses include `mfaSetupRequired` when org-wide MFA is required but the account
    has not enrolled past its grace window.
  - `settings` global writes now audit nested `security` changes.

  **cms-admin**
  - New Security tab with a computed Security Status card, Authentication & MFA, Session
    Policy (with revoke-other-sessions), Access & Request Protection, and Secrets/Encryption/Audit
    cards. Wired to the real `/security` endpoints with source badges, dangerous-change
    confirmations, lockout-safe MFA toggle, validation, and read-only states for non-admins.
    Replaces the previous decorative security toggles.

## 0.36.0

### Minor Changes

- 3667ac2: Add the Settings → Appearance tab: a production-ready brand + admin-theme system.

  **cms-core**
  - New `@actuate-media/cms-core/appearance` subpath module with pure utilities:
    logo-shape classification, WCAG contrast checks, admin accent-token
    generation, and brand/favicon resolution (admin + public fallback chains).
  - `GET /public/brand` (unauthenticated) resolves public logos + favicon
    metadata for the public frontend; `GET /appearance` (authenticated) returns
    stored appearance/brand settings plus resolved admin/public/favicon views and
    per-asset URLs for editor previews.
  - `PUT /globals/:slug` audit logging now records nested `appearance`/`brand`
    changes.

  **cms-admin**
  - New Appearance tab (Branding, Admin Theme, Typography & Motion, live Brand
    Preview) wired to the Media Center for logo/favicon uploads (assets referenced
    by ID, never raw paths), with shape warnings, dark-logo warnings, accent
    contrast validation, source badges, and read-only/permission states.
  - `ThemeProvider` now applies color mode, accent color, sidebar density, content
    font, and reduce-motion live, and feeds the resolved admin brand to the
    sidebar logo (with site-initials fallback).

## 0.35.1

### Patch Changes

- b3f51f2: Fix "Global not found" error on the Settings page (and any global editor) before the first save. The authenticated `GET /globals/:slug` route now returns an empty object for a global that has no document yet — globals are upsert-on-write, so a never-saved slug is a valid empty state rather than a 404. The public `/public/globals/:slug` route keeps its config-gated 404 for genuinely undeclared globals.

## 0.35.0

### Minor Changes

- 58eff9b: Build a production-ready General Settings tab with a computed Site Index Status card.

  **cms-core**
  - Add `getSiteIndexStatus()` / `computeSiteIndexStatus()` (exported from the SEO module) that derives the effective crawl/index state (indexable / protected / blocked / partially_blocked / unknown) from the SAME effective SEO config the public renderer uses — environment, site URL, robots, sitemap, and last audit — plus a `robotsTxtBlocksEntireSite()` helper.
  - Add `GET /seo/index-status` (ADMIN) returning the computed status, per-check rows, and actions.
  - Audit `PUT /globals/settings` with a per-key before/after diff (`settings_changed`, including source + environment).
  - Migrate the `GET /seo/meta/:documentId` preview to read robots defaults from the effective SEO config instead of the legacy `settings` global, so preview, public render, and General Settings share one source of truth.

  **cms-admin**
  - Replace the General tab with a fully wired implementation: Site Index Status card, Site Information (with config-managed source badges + live Site URL validation), Language & Region, and SEO & Robots Defaults — the SEO toggles now read/write the shared `/seo/config` (no duplicate store).
  - Add dirty-state Save/Reset bar, validation summary, confirmation dialogs for dangerous indexing changes (production noindex on; non-production noindex off), loading skeletons, error/retry, and read-only gating for non-admins.
  - Use semantic theme tokens (purple `brand` accent, status colors) so the tab matches the design system in light and dark modes.

## 0.34.0

### Minor Changes

- a6d6674: Forms PII/encryption, retention, audit, export, and MCP tools (PR8).

  **cms-core**
  - **Field-level encryption at rest**: submission values for fields flagged `encrypted` (or high-PII fields when `forms.encryptSensitiveFields` is enabled) are now encrypted with the `enc:v1:` AES-256-GCM envelope before persistence and transparently decrypted on every read path. Plaintext/legacy rows pass through unchanged; missing key on read fails closed.
  - **PII retention**: `forms.retentionDays` is now wired into the cleanup cron, hard-deleting form submissions (and cascading their files) older than the window. Opt-in — disabled by default.
  - **Audit logging**: viewing a single entry now logs `form.entry.viewed` and CSV export logs `form.entries.exported`, both with user id and IP, for PII-access traceability.
  - **CSV export endpoint**: `GET /forms/entries/export?formId=…` streams a decrypted CSV of a form's submissions (bounded to 5000 rows) with formula-injection-safe cells. New exported helpers `exportFormEntries`, `encryptSubmissionData`, `decryptSubmissionData`, `fieldShouldEncrypt`.

  **mcp-server**
  - New `list_form_entries` and `get_form_entry` tools over the decrypted submissions inbox, both requiring an explicit `formId`/`entryId` (no enumeration) and documented as PII-sensitive. `get_form_entry` reads are audited server-side.

## 0.33.0

### Minor Changes

- 9f4192b: Add the Forms notifications + webhooks pipeline (PR6).
  - **Email notifications**: on a clean submission, send admin alerts to the form's configured recipients and an optional submitter autoresponder through the configured `platform.email` adapter. Supports `{{placeholder}}` subject/message templates (HTML-escaped), a fields table that can exclude sensitive/PII fields, and a resolved `Reply-To` from a chosen field.
  - **Per-form webhooks**: new `createFormWebhook` / `listFormWebhooks` / `updateFormWebhook` / `deleteFormWebhook` / `testFormWebhook` / `dispatchFormWebhooks` services. Secrets are stored encrypted and returned only once at creation; payloads are HMAC-SHA256 signed; delivery is SSRF-safe (`safeFetch`) with bounded retries + exponential backoff. Events: `form.submitted`, `form.spam_detected`, `form.entry.starred`, `form.entry.archived`.
  - **API**: `GET/POST /forms/:id/webhooks`, `PATCH/DELETE /forms/:id/webhooks/:webhookId`, `POST /forms/:id/webhooks/:webhookId/test`, and `POST /forms/:id/notifications/test`.
  - Side-effects fire non-blocking from the submission pipeline so a slow or failing channel never delays or breaks the public response. Spam submissions suppress email but still fire `form.spam_detected` webhooks.
  - `EmailAdapter` send options gain optional `replyTo` / `from` (additive, non-breaking).

## 0.32.0

### Minor Changes

- 5a44d79: Add the public form submission pipeline (Forms PR5).
  - New public, unauthenticated endpoints: `GET /public/forms/:slug` (embeddable
    schema projection with server-only fields stripped) and
    `POST /public/forms/:slug/submit`, plus CORS preflight (`OPTIONS`) support.
  - Server-authoritative submission processing: per-field validation +
    type-coercion against the active schema version, HTML sanitization of all
    string values (stored-XSS defense), and dropping of keys not in the schema.
  - Spam protection strategies: honeypot, time-to-submit, email/domain blocklist,
    keyword blocklist, and origin domain allowlist. Spam is persisted but hidden
    from the default inbox.
  - File-upload validation (MIME allowlist + magic-byte sniffing + size cap) wired
    to the configured storage adapter; metadata persisted to `FormSubmissionFile`.
  - Sequential per-form entry numbers assigned in a transaction; client IPs are
    hashed (never stored raw); attribution/UTM captured; submissions rate-limited
    per IP+form and CAPTCHA-verified when configured.
  - The legacy `POST /forms/:id/submit` route now runs through the same pipeline.

## 0.31.0

### Minor Changes

- ca3c1f6: Forms system — data model foundation (PR 1 of 9).

  Adds the persistence layer and shared contracts for the full Forms workflow:
  - New Prisma models: `FormSchemaVersion` (versioned field-schema snapshots so historical submissions always render against the schema they were captured with), `FormSubmissionFile` (file-upload metadata stored via the storage adapter, cascade-deleted with its submission), and `FormWebhook` (per-form signed outbound webhooks).
  - Expanded `FormSubmission` with the submission-workflow columns: `entryNumber`, `schemaVersionId`, `normalizedData`, `senderName/Email/Phone`, `preview`, `unread`/`starred`/`archived`, `spamStatus`, attribution metadata (`ipAddressHash`, `userAgent`, `referrer`, `pageUrl`, `utm*`), `readAt`, `archivedAt`, and `updatedAt`. The migration is safe on populated databases (all additive/defaulted, `updatedAt` backfilled, existing rows marked read).
  - New shared TypeScript contracts under `@actuate-media/cms-core` (`FormDefinition`, `FormField`, `FormFieldType`, `FormSchemaVersion`, `FormSubmission`, `FormSubmissionFile`, `FormNotificationSettings`, `FormSpamSettings`, `FormEmbedSettings`, `FormWebhookConfig`, query-param and stats types).
  - New `forms` site-config section (`FormsConfig`): feature flag, allowed field types, spam defaults, file-upload limits, notification/webhook/autoresponder toggles, retention, PII encryption, and the public submission path.

  No runtime behavior changes yet; subsequent PRs wire services, API routes, admin UI, public submission, notifications, and seed data on top of this model.

- 15d8c08: Forms system — core services + admin API routes (PR 2 of 9).

  Adds the service layer and REST endpoints that back the Forms admin UI:
  - **Forms service** (`forms/service.ts`): `listForms`, `getFormById`, `createForm`, `updateForm`, `duplicateForm`, `archiveForm`/`restoreForm`, `deleteForm` (blocks deletion when submissions exist unless forced), schema versioning (`saveFormSchema`/`getFormSchema` creates a new `FormSchemaVersion` on every save), per-form notification/embed settings, `getFormStats`, and `getFormsSidebarCounts`. Form definitions are stored via the standard document data-layer (inheriting versioning, cache invalidation, and audit logging). Schema validation enforces unique/valid keys, allowed field types, option presence, and the configured field cap.
  - **Entries service** (`forms/entries.ts`): `listEntries` with search + form/unread/starred/archived/spam/date filters (archived and spam excluded by default), `getEntryWithSchema` (renders an entry against the schema version it was captured with), `getEntryCounts` (chip totals + per-form unread map), read/unread/star/archive mutations, `bulkUpdateEntries`, and `deleteEntry`. A normalization layer derives sender/preview from the payload for legacy rows.
  - **API routes**: `GET/POST /forms`, `GET/PATCH/DELETE /forms/:id`, `POST /forms/:id/duplicate`, `GET/PUT /forms/:id/schema`, `GET/PUT /forms/:id/notifications`, `PUT /forms/:id/notify`, `GET/PUT /forms/:id/embed`, `GET /forms/stats`, `GET /forms/sidebar-counts`, `GET /forms/entries`, `GET /forms/entries/counts`, `GET/PATCH/DELETE /forms/entries/:entryId`, and `POST /forms/entries/bulk`. All require an authenticated write role and emit audit-log events.

  Behavior change: `GET /forms` now returns the typed `FormDefinition` shape (including entry counters). `fields` is now the field array; the legacy `fields`-as-count value is available as `fieldCount`, and `submissions` mirrors `totalEntries` so existing admin views keep working until they migrate (PR 3).

  Also fixes a pre-existing sitemap test that omitted the `type: 'page'` collection flag the SEO scoping now requires.

- 8891547: Scope the SEO surfaces to real front-end content and keep the admin out of search indexes.
  - **cms-core**: The Content SEO list, audit, scan, internal-link candidates, **and the XML sitemap** now only include documents from collections explicitly typed `page` or `post` (visible and sitemap-eligible). Structural/utility collections (navigation menus, footers, tags, templates) — which have no `type` — and ad-hoc rows whose collection isn't declared in the config (e.g. benchmark/test data) are excluded, so a menu can no longer leak into the sitemap as an indexable URL. Adds the `frontEndContentCollectionTypes(config)` helper and threads a `collectionTypes` option through `gatherAuditEntities` / `runAndPersistAudit`; `processSeoScan` gains an `allowedCollections` option. The `POST /seo/scan` route now reuses `processSeoScan` (also fixing its case-sensitive `alt=`/`<h1` checks).
  - **cms-admin**: `AdminRoot` now ensures a `noindex, nofollow` robots meta tag while the admin is mounted, so private admin UI is never indexed regardless of how the consumer renders the admin page.

## 0.30.0

### Minor Changes

- 2cd5d3a: SEO production-readiness (Important tier, batch 1 — backend security/access):
  - **Read-endpoint access control.** `/seo/analysis`, `/seo/readability`, `/seo/internal-links`, `/seo/schema` and `/seo/meta` now enforce the target collection's `access.read` rule (and API-key collection scope), so a principal scoped to one collection can no longer pull SEO intelligence for documents in another. Adds a new exported `assertCollectionAccess(collection, operation, ctx, doc?)` helper.
  - **AI audit logging.** Every successful `/ai/seo/*` generation (`generate-field`, `explain-issue`, `generate-schema`, `summarize`) now writes an `ai_seo_generate` audit-log event — these calls are billable and feed public meta/schema, so they need a forensic trail.
  - **Prompt-injection + token-cost hardening.** Untrusted document/issue content interpolated into AI prompts is now length-clamped (2k chars) and fenced with explicit "treat as data, never instructions" delimiters across all SEO AI endpoints, including the title/description branches that previously sent unbounded content.

- 8d45b42: SEO production-readiness (Important tier, batch 2 — backend reliability/correctness):
  - **Severity ordering.** Issue lists (`/seo/issues`, `/seo/overview`, AI summary) now rank by explicit risk (critical → warning → info) instead of an alphabetical `severity` sort that wrongly ranked `info` above `warning`.
  - **Sitemap pagination.** Per-collection sitemaps no longer cap at 5,000 URLs and silently drop the rest. Collections larger than the 50k protocol limit are split across `?page=N` files referenced from the sitemap index (45k URLs/file). Sitemap-index `<loc>` values are now XML-escaped.
  - **Consistent robots resolution.** `composePageMeta` now resolves robots through the shared `resolveRobotsDirectives`, honoring the `robotsPolicy` select field and a new `seo.robots.noIndexNonProduction` guard (plus `defaultNoIndex`/`defaultNoFollow`) so staging/preview deploys aren't indexed. Adds an optional `environment` input.
  - **Audit reliability.** `runAndPersistAudit` refuses to start while another audit is RUNNING (prevents parallel runs duplicating `SeoIssue` rows), `gatherAuditEntities` is bounded with a `take` cap, and the expensive `/seo/scan` endpoint is now rate-limited and bounded.

- 90f53a6: Fix SEO Operations Center production-readiness blockers.
  - **SSRF (`GET /seo/summary`)**: the link-health probe now uses `safeFetch`
    (blocks private/loopback IPs, no redirect following), only probes same-origin
    internal links, skips all probing when `NEXT_PUBLIC_SITE_URL` is unset, and is
    gated to EDITOR+ — matching `/seo/link-health`.
  - **`GET /llms.txt` leak**: now excludes soft-deleted documents and
    internal/system collections (`__seo_config__`, `__*`, etc.) via the shared
    `isInternalCollection` helper, so "deleted" and internal content can no longer
    appear on a public surface.
  - **`PUT /seo/content` data-layer bypass**: SEO field edits now route through the
    new `updateDocumentSeoFields` action, which enforces the collection's
    `access.update` rule, snapshots a `Version` row, recomputes the search index,
    and fires the `afterUpdate`/cache-invalidate effects — while preserving the
    undeclared SEO convention keys the generic field-access layer would strip.
    Inbound SEO fields are now type- and length-validated.
  - **cms-admin**: SEO read fetchers in `seo-service` now re-throw on `cmsApi`
    errors so the error/retry UI works instead of rendering a misleading empty
    fallback (e.g. a fake score of 0 / grade "Critical") on a backend failure.

### Patch Changes

- 1bd4a49: SEO production-readiness Polish tier — post-launch nice-to-haves from the SEO review.

  cms-core:
  - **Markdown-aware heading analysis** — `analyzeContent` and `countH1` now detect
    Markdown ATX (`#`/`##`) and Setext (`===`/`---`) headings in addition to HTML
    tags, so markdown-bodied posts are no longer wrongly flagged "No H1 found" /
    "no subheadings".
  - **Correct default OG image URL** — the auto-generated `og:image` now points at
    `/api/cms/og.png` (where the endpoint is actually mounted) instead of `/og.png`,
    which 404'd for social crawlers.
  - **Consistent environment resolution** — `resolveSeoEnvironment()` centralises the
    production gate, preferring `VERCEL_ENV` over `NODE_ENV` everywhere (preview
    deploys report `NODE_ENV=production`), so `noIndexNonProduction` reliably
    noindexes preview deployments.
  - **Admin route boundary** — the CMS middleware matches `/admin` and `/api/cms` on
    a path-segment boundary, so sibling public routes like `/administrators` or
    `/admin-news` are no longer treated as admin routes.
  - **AI status semantics** — SEO AI endpoints now return `503` when the provider is
    installed-but-unconfigured (vs `501` when the plugin is genuinely missing).
  - Removed a duplicated `gradeForScoreBand` helper in favour of the canonical
    `gradeForSeoScore`.

  cms-admin:
  - SEO filter chips use accessible toggle-button semantics (`aria-pressed`) instead
    of misused `role="tab"`.
  - Crawl-settings toggles use the Radix `Switch` primitive and are disabled while a
    save is in flight.
  - Score progress bars expose an accessible name and `aria-valuetext`; drawers
    without a description no longer trigger Radix's missing-description warning.
  - Redirect CSV export defers object-URL revocation (and mounts the anchor) so the
    download isn't cancelled before it starts.
  - The SEO AI service treats both `501` and `503` as the graceful "AI unavailable"
    state.

## 0.29.0

### Minor Changes

- a3b702f: Media Center foundation (phase 1):
  - New media SEO scoring module (`calculateMediaSeoScore` + types) that grades assets on alt text, filename quality, dimensions, captions, and size.
  - Additive `Media` schema fields (`originalFileName`, `caption`, `description`, `focusKeyword`, `tags`, `publicUrl`, `storageProvider`, `durationSeconds`, `thumbnailUrl`, `responsiveVariants`, `status`, `deletedAt`) plus a `MediaStatus` enum and supporting indexes, with the `0008_media_center` migration.

  All fields are nullable or defaulted, so the change is additive and safe to apply to an existing database.

- 5fa84d3: Media library now reports real usage. `GET /media` joins the `MediaUsage` table and returns a `usedOn` array (page title + admin editor path) per asset, so the "orphaned" indicator is accurate instead of flagging every file. Adds `POST /ai/media-metadata`, which generates accessible alt text (and a derived title) for an image via the AI vision provider, with a graceful 501 when the AI plugin/provider isn't configured.
- 5fa84d3: SEO Operations Center — a full detect → prioritize → explain → route → fix → audit → generate workflow.

  **cms-core**
  - Additive `0009_seo` migration (mirrored to `apps/dev`): `SEOAuditRun`, `SEOIssue` (+ severity/status/category enums), `Redirect` extensions (`hits`, `lastHitAt`, `enabled`, `priority`, `preserveQuery`, `source`), `Redirect404Hit`, and `RedirectSuggestion`. All columns are nullable or defaulted, so it is safe to apply to an existing database.
  - Pure, explainable scoring model (`seo/score.ts`): `calculatePageSeoScore` / `calculateSiteSeoScore` with documented weights and breakdowns.
  - Persistent audit engine (`seo/audit-engine.ts`) with cross-run issue lifecycle (open / resolved / ignored / recurring) and a redirect engine (chain/loop detection, safe flatten, hit tracking, 404 logging, gated AI 404 recovery).
  - New endpoints: `POST /seo/audit`, `GET /seo/audits[/:id]`, `GET /seo/issues[/:id]`, issue `resolve`/`ignore`/`reopen`/`fix`, `GET /seo/overview`, redirect CRUD + import/export + chains/loops/flatten/suggest, technical settings (`/seo/sitemap/*`, `/seo/crawl-settings`, `/seo/robots`), and approval-gated `/ai/seo/*` (generate-field, explain-issue, generate-schema, summarize) that degrade gracefully with 501 when `plugin-ai` is unconfigured.
  - `/resolve` now returns Next.js-ready `metadata` plus validated JSON-LD; public `robots.txt` honors the admin-edited body.

  **cms-admin**
  - Rebuilt `SEO.tsx` into a five-tab Operations Center (Overview / Content / Technical / Redirects / Audit) with `?tab=` deep-link routing, dynamic badge counts, a typed `seo-service.ts` client, reusable token-only primitives and a focus-trapped drawer, editor/fix/copilot drawers, and full loading / empty / error / permission states.

  **mcp-server**
  - New SEO tools: `seo_overview`, `run_seo_audit`, `list_seo_audit_runs`, `list_seo_issues`, `get_seo_issue`, `set_seo_issue_status`, `list_redirect_rules`, `seo_redirect_overview`, `seo_redirect_chains`.

  **create-actuate-cms**
  - Scaffolded projects now ship a redirect-resolving `middleware.ts` backed by the database redirect table.

### Patch Changes

- 766b63c: Surface database-unavailable errors during first-run setup instead of masking them as "setup required". `checkSetupRequired()` and `createInitialAdmin()` now rethrow connection/initialization failures (Prisma `P1001`/`P1002`, `ECONNREFUSED`, `PrismaClientInitializationError`, etc.) so the `/setup/status` and `/setup/create-admin` endpoints return a 5xx for a DB outage rather than silently rendering the account-creation wizard and then failing with an opaque 400.

## 0.28.1

### Patch Changes

- fa54768: Accept SMTP.com's `SMTP_KEY` / `SMTP_CHANNEL` / `SMTP_EMAIL` env vars as
  aliases for `SMTPCOM_API_KEY` / `SMTPCOM_CHANNEL` / `EMAIL_FROM`.

  `resolveEmailConfigFromEnv()` now infers the `smtpcom` provider from either
  naming scheme (the generic relay's `SMTP_HOST` still takes precedence when
  present), and the `/health` env diagnostic recognises the same keys so a
  correctly-configured SMTP.com deployment no longer warns about a missing
  `RESEND_API_KEY`.

## 0.28.0

### Minor Changes

- bdeadb2: Resolve real author and related-posts data for post-type documents.

  `GET /resolve` now returns a `post` object for post-type collections:
  - `post.author` — `{ name, avatarUrl }` derived from the document's creator, so the Author Bio section shows the real author instead of a placeholder.
  - `post.relatedPosts` — up to 3 recent published siblings in the same collection (`{ title, url, excerpt }`), so the Related Posts section links real content.

  Both fields are additive and optional; existing consumers are unaffected.

- 1768a8a: Render the post type's template header on the public site so the live page matches the editor canvas and preview.
  - `GET /resolve` now returns `template.header` (a normalized `PostHeaderConfig`) for post-type documents, resolved from the post type's `post-templates` doc at read time. The template stays the single source of truth, so editing it restyles every post of that type without re-saving each post.
  - New `computeReadingTime()` export drives the header's "min read" figure from the post body (200 wpm, HTML stripped, min 1 minute) instead of the previous hardcoded "5 min read".

- d85e89e: Add a server-side aggregate `GET /posts` endpoint for the admin Posts area.
  Filtering, sorting and pagination now run at the database level (one query plus
  one count) and respect per-collection read scope, so the Posts list scales past
  the old ~500-row-per-type client cap instead of pulling every document into the
  browser. The admin `fetchPosts` service now calls this endpoint; its public
  `Post` / `PostType` / `PostAuthor` shapes are unchanged.

### Patch Changes

- 5567daf: Post templates: seed sensible defaults and route the admin through the dedicated endpoint.
  - A post type with no saved template now returns the default starting template (centered header + article body + related posts) instead of an empty one, so new posts and the template editor are never blank. Applied on the server (`GET /post-templates/:type`) and defended client-side.
  - The admin post-editor service now reads/writes templates through the dedicated, audited `/post-templates/:type` endpoints (scoped + logged) instead of the raw `post-templates` collection API.

## 0.27.4

### Patch Changes

- c091125: Recognise Vercel Marketplace Upstash credentials. The Vercel Upstash (Redis/KV) integration provisions `KV_REST_API_URL` / `KV_REST_API_TOKEN`, not `UPSTASH_REDIS_REST_URL` / `UPSTASH_REDIS_REST_TOKEN`, so a correctly-provisioned store previously fell back to the in-memory rate limiter and the `/health` check kept warning.

  The rate limiter (`createRateLimiter` / `resolveUpstashCredentials`), the platform-vercel rate-limit adapter, and the env health check now read the `KV_REST_API_*` names as a fallback (native `UPSTASH_*` names still win when both are present). No config change is needed for stores provisioned through the Vercel Marketplace.

## 0.27.3

### Patch Changes

- 6f05e8d: Make the email health check (`RESEND_API_KEY` entry in `validateEnvShape` / `/api/cms/health`) provider-aware. It now reports "Configured" when any supported email provider is set up via env (SMTP, SES, or SMTP.com), instead of always warning about a missing Resend key. Mirrors the env resolution in `@actuate-media/plugin-email`.

## 0.27.2

### Patch Changes

- 07f52af: Accept Prisma Accelerate / Prisma Postgres connection schemes (`prisma://`,
  `prisma+postgres://`) in the `DATABASE_URL` env-shape validator. Previously the
  `/health` and admin diagnostics endpoints flagged a valid Accelerate URL as an
  invalid scheme, reporting the instance as unhealthy even though the runtime
  connects successfully through the Accelerate transport.

## 0.27.1

### Patch Changes

- 4fb0097: Dashboard performance: cache the expensive SEO compute and reduce refetch churn.
  - **cms-core:** `/stats` now caches its average-SEO-score computation per content-scope for a short TTL (60s). That score otherwise reads and scans the `data` JSON of up to 200 published documents on every dashboard load — the heaviest part of the endpoint. On a warm cache the read is skipped entirely; transient DB failures are not cached. Module-level so it survives across warm requests; a cold start recomputes once.
  - **cms-admin:** `useApiData` gains an opt-in `cacheMs` option. When set, successful responses are cached in-memory by endpoint and reused on the next mount (cache-first), so views that remount frequently don't re-issue identical requests. `refetch()` always bypasses the cache. Default behaviour is unchanged for existing callers. The Dashboard enables a 30s cache on its four requests, so navigating away and back no longer re-fires them; first load and independent streaming are preserved.

## 0.27.0

### Minor Changes

- 91885de: Add a section-aware bridge so AI agents (e.g. in Cursor / VS Code) can build pages section-by-section through MCP, producing real, editable, publishable Page Editor sections rather than disposable markup.
  - **cms-core**: new `@actuate-media/cms-core/page-sections` subpath exporting the flat Page Editor section registry, pure section-array helpers, and a deterministic `validateSectionContent`. This is now the single source of truth shared by the admin editor, the REST API, and MCP. Adds REST routes: `GET /page-sections/types`, `POST /page-sections/validate`, and section CRUD over a page document's `data.sections` (`GET|POST /page-sections/:collection/:id`, `PATCH|DELETE /page-sections/:collection/:id/:sectionId`, `POST /page-sections/:collection/:id/reorder`). Mutations go through the audited `updateDocument` path (versions, hooks, cache invalidation, live-preview push) and reuse collection scope checks. Writes are rejected on collections that don't declare a `sections` JSON field.
  - **mcp-server**: nine new tools — `list_section_types`, `get_section_schema`, `validate_section_content`, `list_page_sections`, `add_page_section`, `update_page_section`, `reorder_page_sections`, `remove_page_section`, `toggle_page_section_visibility`.
  - **cms-admin**: the Page Editor section registry and section helpers now re-export from `@actuate-media/cms-core/page-sections`; per-section publish validation is delegated to the shared `validateSectionContent`. No behavior change — internal refactor for a single source of truth.

- 91885de: Add a design-driven Posts template system that mirrors the Pages section editor.
  - **cms-core**: tag section types with a `scope` (`page` | `post`), add post-oriented section types (`article-body`, `quote`, `author-bio`, `related-posts`, `gallery`), `PostHeaderConfig` / `PostTemplate` types, the `getSectionTypesForScope` helper, and new field types (`richText`, `boolean`, `relationship`, `gallery`). Adds `GET/PUT /post-templates/:type` API routes and a `?scope=` filter on `GET /page-sections/types`.
  - **cms-admin**: new `PostSectionEditor`, `PostTemplateEditor` (with header panel), `PostPreview`, post section renderers, and a `post-editor-service` data layer. The Add Section gallery now filters by scope, and post type cards expose an "Edit template" action.
  - **mcp-server**: scope-aware `list_section_types`, plus `get_post_template` and `save_post_template` tools so an external AI agent can read and build a post type's template.

### Patch Changes

- 91885de: Add the Posts admin area (All Posts + Post Types).
  - `/posts` aggregates every collection marked `type: 'post'` (or
    `admin.group: 'Posts'`) into a single sortable, filterable table
    with type tabs, search, status/author filters, bulk actions
    (publish / move to draft / delete), and pagination. Counts come
    from the data layer, not constants.
  - `/posts/types` shows a card grid per post-type collection with
    accent border, icon, description, post count, field count, and
    URL base — plus a dashed "+ New Post Type" card linking to setup
    instructions.
  - `/posts/new` lands on a type picker before opening the existing
    per-collection editor.
  - `/posts/types/new` offers a **Quick Post Type** gallery (Case
    Studies, News, Portfolio, Events, Team) that pre-fills a tailored
    field set, icon, and accent color, plus a manual form — both feed a
    paste-ready `actuate.config.ts` snippet generator. Collections
    remain code-defined because the read-only serverless filesystem
    rules out runtime config writes.
  - Sidebar collapses every post-type collection under a single
    "Posts" parent with fixed `All Posts` / `Post Types` children.
  - `/api/cms/collections` now surfaces `admin.description`, `icon`,
    `color`, and `group` so the admin client can render type cards
    and tabs without a separate fetch.
  - New `admin.color` field on `CollectionDefinition` (accent token).

  V1 deliberately skips an in-app Post Type editor / field builder —
  the cards click through to the existing per-collection list and
  new Post Types are added via the snippet generator. A follow-up PR
  introduces a DB-overlay for visual metadata (icon / color /
  description) editable from the admin UI.

- 4451a57: `/api/cms/stats` now filters the dashboard's recent activity feed,
  total document count, per-collection counts, status counts, and SEO
  score corpus to "user-facing CMS content" only.
  - If the CMS config registers collections (the production shape),
    those slugs are the allowlist. Anything else — benchmark fixtures,
    orphan rows from removed collections, test data — is hidden.
  - If `config.collections` is empty (dev consumers that ship
    collections to the admin client only), the endpoint falls back to
    a small denylist of internal slugs (`benchmarks`, `bench`).

  This stops the benchmark suite's `benchmarks` collection from leaking
  into the dashboard.

## 0.26.0

### Minor Changes

- 560d1de: Dashboard redesign + real-data backends.

  `@actuate-media/cms-admin`:
  - Sidebar now uses explicit Content and Settings group headers with nested
    Pages children (Saved Sections, Templates). The collapse control moved
    to the sidebar footer.
  - Header hoists primary page actions (e.g. "+ New Post", "View Site")
    into the top utility bar so the page hero is no longer the source of
    truth for those affordances. The notification bell is wired to the
    real `NotificationBell` component.
  - Dashboard greeting picks the user's first name, and the "Delivery API"
    - "Content Health" cards now read live data from new endpoints.

  `@actuate-media/cms-core`:
  - New `ApiRequestMetric` table (minute-bucketed counter + log-scale
    latency histogram) and `recordApiRequest` instrumentation wired into
    `handler-factory.ts`. Writes are fire-and-forget — request paths are
    never blocked on the metric DB.
  - New `ContentIssue` table + nightly `processContentHealthScan` cron
    (`/api/cms/cron/content-health`) that runs a pure-logic scanner for
    missing meta descriptions, missing alt text, outdated content
    (>90 days), and broken internal links via HEAD probes.
  - New endpoints `GET /api/cms/stats/api-delivery` and
    `GET /api/cms/stats/content-health` return the dashboard's
    aggregates with stable, never-undefined response shapes.
  - Migration `0007_dashboard_metrics` adds both tables with the indexes
    the new queries need. Safe to apply to populated DBs (no backfill).

  `create-actuate-cms`:
  - Scaffolded `vercel.json` now includes the nightly content-health
    cron entry, and the deployment guide documents it.

## 0.25.1

### Patch Changes

- e72948a: Fix `compareToDummyHash` eager-init parity test: also warm Node's
  libuv crypto worker pool before measuring. PR #170 awaited the
  eager-init promise but missed that the first PBKDF2 call across the
  entire process pays an additional ~100-150ms cold-start to spawn the
  worker thread + initialise the C++ binding context. The first run on
  `main` (run 26599207053) failed with e1=291ms vs e2=150ms — same ~2×
  ratio as the historical bug, but caused by worker-pool warmup rather
  than a real regression. The test now does a throwaway warmup call
  after the eager-init wait so both measurements run on a hot worker
  pool.
- 40d48da: Two flake fixes from the OSS maintenance pass:
  - **`NotificationBell` SSE/REST race.** The `seenIdsRef` lookup index
    was reconciled with `items` via `useEffect([items])`, which runs
    after React commits. An SSE push that arrived between a REST
    refresh resolving and the effect running would read a stale ref,
    fall through the de-dup gate, and append a duplicate row. The ref
    is now maintained synchronously at every items-mutation call site,
    and the SSE handler also re-checks the latest snapshot inside the
    `setItems` updater as a final safety net.
  - **`compareToDummyHash` eager-init test determinism.** Exported a
    test-only `_waitForDummyHashReady()` so the timing-parity test can
    measure verify-only latency (rather than racing the eager hash
    precompute on a cold module). Added a paired test that asserts the
    eager-init promise resolves with no work pending on the consumer
    side — catches future refactors that accidentally drop the
    top-level invocation.

## 0.25.0

### Minor Changes

- a0f6eba: Rich `@`-mention picker for comments (last Phase 7 carry-over).

  **`cms-core`**: New `GET /api/cms/users/search?q=<query>` endpoint —
  authenticated (any role), case-insensitive `contains` matching against
  `name` + `email`, capped at 10 results, returns only `{ id, name,
email }` (no role / hash / audit metadata). Empty / whitespace
  queries return an empty array so the endpoint can't be used as a
  directory-enumeration tool.

  **`cms-admin`**: New `<MentionableTextarea>` component plus pure
  helpers in `lib/mention-picker.ts` (`detectMentionContext`,
  `insertMentionToken`, `searchMentionUsers`). Wired into the three
  comment composer surfaces in `CommentSidePanel`: the new-thread
  textarea, the reply input, and the in-place edit textarea. The
  picker is keyboard-driven (Arrow Up / Down, Enter / Tab to commit,
  Escape to close) with a Radix-style listbox + ARIA roles, and
  gracefully handles slow responses (drops stale requests when typing
  quickly).

  **`mcp-server`**: New `search_users(query)` tool exposes the same
  lookup so AI agents can resolve teammates by name fragment before
  drafting a comment. Wire format is unchanged — the picker writes
  `@<userId>` tokens that the existing `extractMentionedUserIds`
  parser already understands, so no notification schema migration is
  required.

  No breaking changes.

## 0.24.0

### Minor Changes

- 4732184: Per-user SSE push channel for the notification bell (Phase 7 carry-over).

  `@actuate-media/cms-core` exposes a new authenticated SSE endpoint:

  GET /api/cms/notifications/stream

  The stream pushes each `comment_reply`, `comment_mention`, and
  `comment_resolved` notification the instant it's persisted to
  `DocumentNotification`. The wire format is `event: ready` (once on
  connect, with the subscribed `userId`) followed by `event:
notification` frames carrying the full DTO, plus a 25 s heartbeat
  keepalive and a 4 min server-initiated close so `EventSource`
  reconnects before platform max-duration caps fire.

  `@actuate-media/cms-admin` switches `NotificationBell` over to an
  `EventSource`-backed subscription with the existing REST endpoints as
  a convergence backstop (the in-process bus is single-node by design;
  the poller now runs every 60 s instead of 30 s). The new
  `subscribeNotificationStream` helper is exported for any consumer that
  wants to drive its own UI off the same channel.

  Both packages bump `minor`: the cms-core change adds a new public
  route and a new realtime export surface; the cms-admin change adds a
  new `NotificationsApi.subscribe` member and exports the
  `NotificationStreamHandle` / `NotificationStreamOptions` /
  `EventSourceLike` types. No breaking changes — callers who don't pass
  a custom `api` continue to work unchanged.

## 0.23.0

### Minor Changes

- 21db54b: Phase 7 PR F — MCP-parity audit stylistic cleanup.

  This is the final follow-up to `docs/mcp-parity-audit.md`. After this
  PR the audit is fully retired — P0, P1, and stylistic notes are all
  addressed.

  ### `@actuate-media/cms-core`

  Adds `GET /api/cms/collections`: a lightweight discovery endpoint
  sourced directly from `getActuateConfig()`. Returns
  `{ data: CollectionMeta[] }` with one entry per configured collection
  (`slug`, `labels`, `type`, `urlPrefix`, `hidden`, `fieldCount`,
  `fields[{name,type,required}]`). Unauthenticated by design — the
  response only exposes schema-shape metadata that is already public via
  `/openapi.json` — and the contract is intentionally tight (only
  `name` / `type` / `required` per field, no validators, hooks, or
  access closures leak).

  ### `@actuate-media/mcp-server`

  Three audit-mandated stylistic cleanups:
  1. **AI-create-X collapse (6 tools → 3).** Removed `create_service`,
     `create_location`, `create_team_member`. Their behaviour is folded
     into `create_in_collection` via an optional `template` arg
     (`'service' | 'location' | 'team_member' | 'generic'`) that
     selects a built-in prompt-enrichment recipe plus a default
     collection slug. Kept `create_blog_post` and `create_case_study`
     as type-specific tools because their field-sets are distinctive
     enough to be standalone agent intents.

     **Breaking change** (acknowledged — pre-1.0, audit-recommended):
     the three removed tool names will return "tool not found" on next
     release. Migration is mechanical:
     - `create_service({ category, ... })`
       → `create_in_collection({ template: 'service', category, ... })`
     - `create_location({ cityState, ... })`
       → `create_in_collection({ template: 'location', cityState, ... })`
     - `create_team_member({ name, role, ... })`
       → `create_in_collection({ template: 'team_member', name, role, ... })`

  2. **`audit_document` split.** Replaced with `audit_document_by_id`
     (requires `collection` + `id`) and `audit_inline_content` (requires
     `title` + `body`). Both forward to the same `/ai/audit-document`
     endpoint; the split exists so agents pick the right mode at the
     tool boundary instead of constructing a half-filled blob and
     discovering the "must provide either (collection,id) or body" rule
     via a server-side error.

     **Breaking change** (audit-recommended): `audit_document` is
     removed. Migration: pick the explicit tool that matches the
     intent — `audit_document_by_id` for stored docs,
     `audit_inline_content` for ad-hoc text.

  3. **`list_collections` reliability.** Now prefers the new
     `/api/cms/collections` endpoint as the primary source so the tool
     stays accurate when the OpenAPI generator drifts. Falls back to
     OpenAPI only on a real HTTP 404 (older CMS server) — the decision
     uses `ActuateClientError.status` (newly exported from the client),
     not a regex against the error message, so an unrelated server
     error that happens to contain "not found" in its body no longer
     triggers the fallback. Non-404 errors propagate.

     **Return-shape change** (consistent contract): `data` is now
     ALWAYS a `CollectionSummary[]` array regardless of which path
     responded. The OpenAPI fallback path normalises into the same
     array shape that the new `/collections` endpoint emits, so
     callers no longer need to branch on `source` to know whether they
     got an array or an object map. The returned envelope is
     `{ data: CollectionSummary[], source: 'collections' | 'openapi' }`.

     **Newly exported:** `ActuateClientError` (subclass of `Error`
     carrying `status: number`). Useful for any consumer that needs
     to branch on the HTTP status from a `request()` failure.

  ### Tests
  - mcp-server: 113 tests total (was 97 in PR E). 16 new — template
    routing for each of service / location / team_member, audit split
    validation, list_collections primary + fallback + 500-propagation.
  - cms-core: 4 new tests for the discovery endpoint covering field
    summaries, `hidden` flag, missing-config 500, and the
    no-leak-of-validators-or-hooks contract.

## 0.22.0

### Minor Changes

- abc7924: Phase 5.5 #1: tag-based edge caching for public document reads.
  - **`@actuate-media/client`**: `list`, `get`, `global`, `resolve`, and
    `listMedia` now automatically attach per-resource cache tags
    (`actuate:collection:<slug>`, `actuate:doc:<id>`,
    `actuate:global:<slug>`, `actuate:path:<path>`) alongside the existing
    wholesale `actuate` tag. New exported helpers `actuateTag(kind, slug)`
    and `actuateTags(kind, ...)` return the canonical tag set so a
    webhook handler can call `revalidateTag()` with the exact same
    strings the SDK uses for reads. The `tags` field on `defaultCache`
    remains opt-in; resource tags are merged on top of it and deduped.
    `revalidate: false` (no-store) bypasses tag emission entirely.
  - **`@actuate-media/cms-core`**: new top-level `cache?: CacheConfig`
    field on `ActuateCMSConfig`. When `cache.publicReads.enabled` is true
    the four read endpoints (`GET /collections/:slug`,
    `GET /collections/:slug/:id`, `GET /globals/:slug`, `GET /resolve`)
    emit `Cache-Control: public, s-maxage=<maxAge>, stale-while-revalidate=<swr>`
    for unauthenticated and API-key requests. Session-cookie requests
    always get `private, no-store`; preview-token resolve requests bypass
    the cache. New `cache.onInvalidate` hook fires after every
    create/update/delete and global update with the canonical tag set;
    failures are swallowed so cache invalidation can never roll back a
    successful write. The cache layer is off by default — existing
    consumers see no behaviour change until they opt in. See
    `docs/caching.md` for the full contract.

- d0a5275: Phase 5.5 #5 — Vercel Runtime Cache (layer 2) for public reads.

  Adds an optional second cache layer between the CDN tier shipped in
  Phase 5.5 #1 and the database. When a CDN entry expires, the request
  reaches the function — and a runtime-cache hit lets the function
  return in ~10–20 ms instead of paying the full ~80 ms Postgres
  roundtrip.
  - New `CacheConfig.runtimeCache` config field — `{ adapter, ttl? }`.
    The adapter contract (`get` + `set` with TTL + tags) matches Vercel's
    `getCache()` from `@vercel/functions` so it can be passed through
    unchanged. Self-hosted projects can plug in any KV store (Upstash,
    in-memory, Redis) by satisfying the same interface.
  - New `cachedRead` helper in `packages/cms-core/src/cache/http.ts`.
    Wraps the four public read endpoints
    (`GET /collections/:slug`, `GET /collections/:slug/:id`,
    `GET /globals/:slug`, `GET /resolve`). Stamps responses with
    `X-Actuate-Cache: HIT|MISS`. Session-cookie requests bypass the
    layer entirely (per-user data must never enter a shared cache),
    matching the existing CDN-layer rule.
  - New `cacheKeyFor` helper — derives cache keys from `pathname` plus
    sorted query, so `?page=1&pageSize=20` and `?pageSize=20&page=1`
    share a cache entry while `?populate=author` cleanly separates
    populated/non-populated reads.
  - Adapter failures (`get` or `set` throwing) are logged and swallowed.
    Cache outages degrade silently to a direct DB read; user-visible
    requests never fail because the cache fell over.
  - Size guard: payloads > ~1.5 MB skip the `set` call to stay under
    Vercel's 2 MB runtime-cache item ceiling.

  No public API changes for existing consumers. The new field is
  optional and the four read endpoints behave identically when it's
  unset.

### Patch Changes

- 1957416: Fix silent production regression introduced in PR #132 (`relationLoadStrategy: 'join'` queries on the dashboard recent-docs panel, document versions list, and `doc_get` bench scenario):
  - Add the `relationJoins` Preview-feature flag to `packages/cms-core/prisma/schema.prisma` and `apps/dev/prisma/schema.prisma`. Without it Prisma 7 rejects `relationLoadStrategy: 'join'` as an "Unknown argument" — the optimisation never actually fired, the dashboard recent-docs panel silently returned empty (`safeFindMany` swallowed the error), the versions panel returned 500, and `/api/bench` reported 100% errors for `doc_get`.
  - Stop the `safeCount` / `safeFindMany` / `safeGroupBy` helpers in `api/handlers.ts` from swallowing all errors. They still return the empty value (`0` / `[]`) to keep the request path alive when an optional Prisma model is missing — but now log unexpected errors to `console.warn` so the next time a query is silently failing in production it shows up in Vercel + Sentry logs.
  - New regression test (`schema-preview-features.test.ts`) pins both `relationJoins` and `fullTextSearchPostgres` on the schema so this class of bug can't recur.

  `apps/dev`: regenerated Prisma client (no source changes — the new flag enables the existing `relationLoadStrategy: 'join'` calls).

- 93a910a: Phase 5.5 #2 + #3 — cache-miss latency pass.
  - `populateRelations` / `validateRelations` now batch every relation
    fetch into a single `RelationLookup.fetchByIds(allCollections, allIds)`
    call instead of issuing one DB round-trip per target collection.
    Documents that link into N collections now cost 1 round-trip on
    populate, not N.
  - Dashboard recent-docs and version-list queries opt into Prisma 7's
    `relationLoadStrategy: 'join'`, folding the `createdBy` / `updatedBy`
    / `changedBy` user joins into one SQL round-trip. Same shape applied
    to the benchmarks Prisma provider's `getDocument` scenario so
    `/api/bench` measures the new geometry.
  - `apps/dev` API routes pin `preferredRegion = 'iad1'` so the deployed
    Vercel Function co-locates with the Prisma Postgres tier the
    Marketplace integration provisions in the same region. Override
    documented in `docs/deployment.md`.

- 0f50407: Security: bump `sanitize-html` from 2.17.3 to 2.17.4 to fix a default XSS via raw-text `<xmp>` passthrough (GHSA-vjp9-mfvj-w4qg, CRITICAL). The advisory only fires when content is rendered with the default `allowedTags`; cms-core's bundled defaults are unaffected, but the patch closes the entire class of bypasses for any downstream caller that uses the bundled sanitizer.

## 0.21.0

### Minor Changes

- 1675eac: Add component-aware blocks — Phase 4 foundation slice.

  Ships the new `@actuate-media/component-blocks` package, which reads a directory of React component source files and emits a typed `Manifest` describing every exported component's props (`string` / `number` / `boolean` / `enum` / `union` / `object` / `array`). Same-file type aliases are resolved automatically. Includes a CLI (`actuate-component-blocks extract --root <dir> --out <file>`) and a programmatic `extractManifest()` API.

  Adds the `componentBlock` field type to `@actuate-media/cms-core`, which consumes a manifest and validates stored block values — rejecting unknown components, missing required props, wrong-type values, and out-of-set enum picks. Field-level `allow` and `defaultComponent` options are supported.

  This is the foundation slice; admin form generation, migration-diff CLI, and discriminated-union pickers ship in follow-up PRs. See `docs/component-blocks.md`.

## 0.20.0

### Minor Changes

- 8f2d3f8: Redesign the admin Dashboard and fix the recurring "content cut off on the left" layout bug.

  **Dashboard (`@actuate-media/cms-admin`)**
  - New `Dashboard.tsx` matching the production design: time-of-day greeting, hero action row (New Post / View Site), horizontally-scrollable quick actions, five stat cards, two-column main grid (Recent Activity + Publishing Queue + Content Health), and a four-tile Content Delivery footer (Total Documents, Scheduled, Form Responses, Active Webhooks).
  - All data derived from the existing `/stats` and `/health` endpoints — no additional network round-trips. Recent activity, publishing queue, content health, and delivery tiles are computed client-side via `useMemo` so the backend never blocks the frontend.
  - Fully responsive: stat cards collapse from 5 → 3 → 2 columns at `lg` / `sm` / mobile breakpoints, the main grid stacks below `lg`, and quick actions scroll horizontally on narrow screens.
  - Deterministic per-author avatar colors (hash-based) so the activity feed is stable across renders without state or extra fetches.

  **Layout cutoff fix (`@actuate-media/cms-admin`)**
  - Added `lg:shrink-0` to the desktop Sidebar wrapper. Without it, on viewports between `lg` (1024px) and ~1280px the flex sidebar column could compress below the Sidebar's intrinsic 256px width, causing dashboard content to overflow into and visually overlap the sidebar. Combined with the existing `min-w-0` on the main column, the row now reliably renders as `[256px Sidebar][1fr Main]` at every desktop width.
  - Dashboard is wrapped in `max-w-[1400px] mx-auto w-full` so on ultra-wide displays the content stays in a comfortable reading column instead of stretching edge-to-edge.

  **Stats endpoint extension (`@actuate-media/cms-core`)**
  - `GET /stats` now returns `webhookCount` and `webhookActiveCount`, calculated via two cheap `safeCount` calls in the existing `Promise.allSettled` batch. The dashboard's "Active Webhooks" tile uses these instead of issuing a second request, keeping page load to a single stats fetch.

- 0ac9024: Inline AI co-author HTTP endpoint + MCP tool (Phase 2).

  Adds `POST /api/cms/ai/coauthor` with four sub-actions (`rewrite`,
  `expand`, `compress`, `proofread`) that wrap the plugin-ai writing
  primitives behind a single endpoint. Rate-limited under the same
  bucket as content generation (20 calls/hour/user). Missing provider
  configuration surfaces as HTTP 501 instead of 500.

  Exposed to AI agents via the MCP tool `coauthor_text` with the same
  input shape, so the admin UI and AI agents can drive identical
  text-transformation workflows.

  Tests: 5 endpoint tests covering each action + validation errors.

- 5d18097: Wire per-type SEO defaults into the admin Settings panel — the last loose end from Slice A (SEO Completeness).

  **`cms-core`**
  - New `seo/config-store` module persists admin edits in a single reserved-slug document (`__seo_config__`) and merges them on top of the static `actuate.config.ts` `seo` block at runtime, field-by-field.
  - New endpoints `GET /seo/config` and `PUT /seo/config` (admin-only). Saving uses partial-update semantics so editing the site-wide section never wipes per-collection overrides (and vice versa).
  - `/sitemap.xml`, `/sitemaps/:slug.xml`, `/robots.txt`, `/og.png`, and `/resolve` now load the merged config per request via `loadEffectiveConfig()`, so admin changes are live immediately with no redeploy.
  - Empty strings in PUT payloads are treated as "unset" (fall back to static config) — the panel is purely additive.

  **`cms-admin`**
  - New **Settings → SEO** tab backed by `<SEOConfigPanel />`. Edits site-wide defaults (siteUrl, siteName, OG image, Twitter handle, change-freq, priority, robots toggles, AI-bot blocking) and per-collection defaults (Schema.org type, archive path, sitemap priority/changefreq, noindex/nofollow, exclude-from-sitemap).
  - Static `actuate.config.ts` values render as input placeholders so admins always see what they're overriding.

  12 new unit tests cover the merge semantics, unset behaviour, DB roundtrip, upsert (no duplicate rows), and DB-error fallback.

- 313a23f: Add Slice F — AI quality pass.

  `plugin-ai` exposes a new `auditDocument()` composer that combines SEO,
  readability (Flesch-Kincaid / Gunning Fog / SMOG averaged + remapped to a
  0-100 score), accessibility, and freshness scoring into a single
  composite grade with prioritized actionable issues. The composer is
  deterministic — it never calls an LLM — so it's cheap enough to invoke
  on every save or debounced keystroke.

  `cms-core` adds two HTTP endpoints:
  - `POST /ai/audit-document` — runs the composite audit against a stored
    document (`{ collection, id }`) or ad-hoc content. Honors field-level
    access control when loading from the DB.
  - `POST /ai/suggest-internal-links` — scans published documents for
    phrases the supplied content could link to, returning ranked
    anchor-text + target-URL suggestions. Optionally scopes to a single
    collection.

  `mcp-server` exposes both endpoints as MCP tools (`audit_document` and
  `suggest_internal_links`) so AI agents can grade drafts and propose
  internal links before publish.

  Docs: `docs/ai-quality-audit.md`.

## 0.19.0

### Minor Changes

- 39f5941: Slice E — Preview & Publishing Polish.

  **Draft preview links**
  - Preview tokens now accept a configurable `ttlSeconds` (clamped to
    `[60, 2,592,000]`, default 7 days). The old 5-minute hard-coded TTL
    was too short for sharing with clients.
  - `POST /preview/token` now verifies the document exists before
    issuing a token, includes the doc's `slug` in the response so the
    admin can build a friendly URL, and records the issuing user in the
    JWT `sub` claim for audit logs.
  - `GET /resolve?preview=<token>` drops the `status: PUBLISHED` filter
    when a valid token is presented, returning draft content at the
    document's real URL. The response includes a `preview: { active,
expiresAt }` block so frontends can render a "preview mode" banner.
    Tokens are bound to a single `(collection, documentId)` — using one
    against a different URL returns `403`.

  **Scheduled publishing**
  - New `POST /collections/:slug/:id/schedule` endpoint sets
    `scheduledAt` and/or `scheduledUnpublishAt` (validated as future
    ISO 8601 timestamps with `unpublish > publish`) and flips status to
    `SCHEDULED`. The existing scheduling cron picks the doc up at the
    scheduled time.
  - New `DELETE /collections/:slug/:id/schedule` cancels a pending
    schedule. `SCHEDULED` docs revert to `DRAFT`; `PUBLISHED` docs with
    a pending unpublish stay live.

  **Admin UI**
  - New **Share preview** toolbar button on `DocumentEdit` opens a
    dialog to generate signed preview URLs with selectable TTL
    (1 hour / 1 day / 7 days / 30 days) and a copy-to-clipboard control.
  - New **Schedule** button in the Status panel opens a datetime picker
    for setting both publish and (optionally) unpublish times. Active
    schedules render an inline blue pill with the times. "Reschedule"
    and "Cancel schedule" supported.
  - New exported components: `SharePreviewLinkDialog`,
    `SchedulePublishDialog`.

  **Tests & docs**
  - 20 new tests: preview adapter TTL clamping, JWT round-trip, secret
    isolation; `/preview/token` + `/resolve?preview=` happy/sad paths;
    schedule create/cancel/validation paths.
  - New `docs/preview-and-scheduling.md` walkthrough; `docs/api-reference.md`
    expanded with the new endpoints.

## 0.18.0

### Minor Changes

- 4433301: Phase 2 Slice D — Content Relations & References.
  - **cms-core**: runtime support for the `relationship` field type — referential validation and opt-in depth-1 population.
    - **Validation**: every create/update verifies that referenced IDs point at live (non-deleted) documents in the declared target collection(s). Broken references fail with a precise error (`Relation field "author" references document "team-x42" which does not exist in collection "team".`). Updates only re-validate fields the caller actually touched.
    - **Population**: pass `?populate=author,relatedPosts` (or `*`) on `GET /collections/:slug/:id` and `GET /resolve` to expand stored IDs into `{ id, collection, title, slug, status, data }` summaries. Batches by target collection (≤ 1 query per collection per request); soft-deleted refs fall back to raw IDs.
    - **Presets**: new `relationField()` and `relatedDocsField()` helpers. Built-in collection presets now wire `posts.relatedPosts → posts`, `case-studies.services → services` (the `locations.servicesOffered → services` relation continues unchanged).
    - **Multi-target**: `relationTo: ['posts', 'case-studies']` continues to work — validator accepts a doc if it lives in any target.
    - Public API: `validateRelations`, `populateRelations`, `collectRelationRefs`, `parsePopulateParam`, `buildRelationLookup`, plus types `RelationLookup`, `RelationSummary`, `PopulateOptions`.
  - **plugin-ai**: the document content generator now skips `relationship` fields when building the LLM prompt (the model can't know your document IDs) and accepts ID strings only on output. Prevents the AI from inventing broken references that the validator would then reject.
  - **23 new tests** in `cms-core/src/__tests__/fields/relations.test.ts` covering ref collection, validation, population, populate-param parsing, multi-target relations, and array-nested relations. Full suite: 620 cms-core tests passing.
  - **Docs**: new `docs/relations.md` (declaration, validation, populate, admin UI, common patterns), `?populate=` documented in `docs/api-reference.md` for both `/collections/:slug/:id` and `/resolve`.
  - **Admin UI**: the existing `RelationshipField` picker continues to work — search, multi-select, status badges, "Create new" shortcut. No changes required.

## 0.17.0

### Minor Changes

- e6a18b5: Phase 2 Slice C — AI Content Authoring for any collection.
  - **plugin-ai**: new `generateDocumentContent()` helper that maps a prompt onto a collection's field schema and returns structured field data ready to persist. Supports tone, target audience, brand voice profile, and existing-content context. Coerces LLM output to declared field types (numbers, booleans, dates, select options, array items) and drops fields the schema doesn't declare.
  - **cms-core**: new `POST /collections/:slug/ai-create` endpoint — the non-page counterpart to `/page-builder/create`. Generates a complete document for any collection, persists as draft, optionally publishes via a follow-up update when the caller has admin scope. Refuses page-builder collections (use `/page-builder/create` instead). Hard caps prompt at 4000 chars and shares the per-user 20/hour AI rate-limit bucket. Returns a `warning` field when create succeeds but publish is denied.
  - **mcp-server**: six new tools — `create_in_collection` (generic) plus per-type sugar tools `create_blog_post`, `create_case_study` (with `clientName` / `industry` hints), `create_service` (`category` hint), `create_location` (`cityState` hint), and `create_team_member` (`name` / `role` hints). Each per-type tool targets the conventional collection slug shipped by the cms-core collection presets, with an explicit `collection` override for sites using custom slugs.
  - 19 new tests (10 unit tests in plugin-ai for the content generator, 9 integration tests in cms-core for the API endpoint covering auth, validation, persistence, publish flow, and the page-builder refusal path).
  - Docs: updated `docs/api-reference.md` with the new endpoint, `docs/mcp.md` and `packages/mcp-server/README.md` with the new tools and example agent prompts.

## 0.16.0

### Minor Changes

- 9377c39: **Post-type Polish (Phase 2, Slice B)** — one-line content types for the eight patterns nearly every marketing site needs, plus matching page-builder starter templates.
  - **Collection presets** — `blogPostCollection()`, `caseStudyCollection()`, `portfolioCollection()`, `serviceCollection()`, `locationCollection()`, `teamMemberCollection()`, `faqCollection()`, `testimonialCollection()`. Each returns a complete `CollectionDefinition` with sensible fields, admin defaults, `seo.defaultSchemaType` set to the right Schema.org type (BlogPosting / Article / CreativeWork / Service / LocalBusiness / Person / FAQPage / Review), archive paths for post-type collections, sitemap priorities, and admin grouping. All accept `CollectionPresetOverrides` (extraFields, seo merge, label / slug / urlPrefix overrides).
  - **Field presets** — reusable bundles you can spread into any collection: `titleField`, `slugField`, `excerptField`, `richContentField`, `featuredImageField`, `tagsField`, `publishedDateField`, `authorField`, `quoteField`, `ctaField`, `seoFields`, `locationFields`, `geoFields`, `priceFields`.
  - **Four new page-builder templates** — `case-study` (hero / metrics / challenge / solution / results / quote / CTA), `portfolio` (cover / info strip / description / gallery / CTA), `service` (hero with CTA / description / benefits / pricing / FAQ), `team-member` (avatar hero / bio / contact links). Joins the existing `blank`, `landing`, `about`, `contact`, `location`, and `blog-post` templates.
  - **Per-collection template suggestion** — `GET /api/cms/page-templates?collection=:slug` now reorders the response so suggested templates bubble to the top, annotates each template with `suggested` + `suggestionRank`, and returns a `suggestion` object pointing at the primary template ID. `getTemplatesForCollection('posts')` exposes the mapping in code.
  - **33 new tests** (16 unit field-preset + 17 unit collection-preset + suggestion lookup). Full suite: 588 passed.
  - **Docs updated** — `docs/configuration.md` adds the Collection Presets, Field Presets, and Page-builder Templates per Type sections.

## 0.15.0

### Minor Changes

- 00ffcf7: **SEO Completeness (Phase 2, Slice A)** — production-grade SEO surfaces wired into the core CMS with zero extra plugins.
  - **New `composePageMeta()` helper** — One-shot SEO composer: pass a resolved document, its collection, and the CMS config and get back a Next.js-ready `metadata` object, computed JSON-LD (Article / BlogPosting / LocalBusiness / Service / etc.), pre-rendered `<meta>` / `<script>` HTML, and the canonical URL. Auto-detects Schema.org type from collection slug (`posts → BlogPosting`, `locations → LocalBusiness`, `services → Service`, …) and respects per-collection `defaultSchemaType` / `defaultRobots` / `defaultOgImage` overrides.
  - **`/resolve` now returns `meta`, `jsonLd`, and `jsonLdHtml`** — SSR consumers (Next.js apps, AI agents, MCP clients) get a ready-to-render meta tag block and JSON-LD `<script>` tag without re-deriving anything from raw doc data.
  - **Public sitemap + robots + OG routes** — `GET /sitemap.xml`, `GET /sitemaps/:slug.xml`, `GET /robots.txt`, and `GET /og.png` are served automatically from the CMS handler once `seo.siteUrl` is set. Each route can be disabled individually (`seo.sitemap.disabled`, `seo.robots.disabled`, `seo.ogImage.disabled`).
  - **Per-collection SEO config** — `CollectionDefinition.seo` now accepts `defaultSchemaType`, `defaultRobots`, `defaultOgImage`, in addition to the existing `sitemapPriority` / `sitemapChangeFreq` / `excludeFromSitemap` / `archivePath`.
  - **Site-wide SEO config** — New top-level `seo` block on `ActuateCMSConfig` for `siteUrl`, `siteName`, `defaultOgImage`, `twitterHandle`, `organization`, `robots`, `sitemap`, and `ogImage` defaults.
  - **AI-bot blocking** — `seo.robots.blockAIBots: true` adds disallow rules for GPTBot, ChatGPT-User, ClaudeBot, Claude-Web, anthropic-ai, Bytespider, CCBot, and Google-Extended.
  - **27 new tests** covering `composePageMeta()` (15) and the public SEO routes (12).
  - **Updated docs** — `docs/seo.md` adds the zero-config-surfaces section and `composePageMeta` walkthrough; `docs/api-reference.md` documents the new public routes and the enriched `/resolve` response.

## 0.14.0

### Minor Changes

- e87471d: Phase 1: AI access. API key authentication (act*sk*\* bearer tokens) with scope-based authorization across collections, globals, media, and the page builder. New /api-keys admin endpoints + UI for creating, listing, and revoking keys with password reauth. CSRF is skipped for API-key requests. New /page-builder/create one-shot endpoint that generates an AI page and persists it as a document in a single call. New @actuate-media/mcp-server package exposes the CMS to AI agents (Claude, Cursor, ChatGPT, etc.) via the Model Context Protocol — full CRUD, globals, media, page generation, and one-shot create tools. Adds 44 new tests covering the API key auth path, scope enforcement, CSRF skip, and MCP client/tools.

## 0.13.0

### Minor Changes

- 9cf5d10: Engineering audit — medium and low items.

  **New features**
  - `validateEnvShape()` (`diagnostics/env.ts`): runtime check that `CMS_SECRET`, `CMS_ENCRYPTION_KEY`, `DATABASE_URL`, `BLOB_READ_WRITE_TOKEN`, `UPSTASH_REDIS_*`, `RESEND_API_KEY`, and `CRON_SECRET` are not just present but well-formed (length, hex, placeholder detection). Surfaced via `/api/cms/health` so misconfiguration is caught at deploy time, not at first runtime use. Adds an `unhealthy` status when any required var is malformed.
  - `createLogger(scope)` (`diagnostics/logger.ts`): structured per-scope logger that respects `ACTUATE_LOG_LEVEL` (`silent`/`error`/`warn`/`info`/`debug`) and `ACTUATE_LOG_FORMAT=json` for log-aggregator ingestion. Defaults to `warn` in production, `info` elsewhere.
  - `getActuateConfig()` / `setActuateConfig()` (`config/runtime.ts`): typed accessor for the per-process CMS config, replacing scattered `(globalThis as any).__actuateConfig` casts. Documents the single-process / set-once contract in detail.

  **Hardening**
  - `/media/upload` now requires a valid `Content-Length` header before parsing the body, closing a chunked-encoding bypass that allowed unbounded memory consumption (audit M7).
  - In-memory rate limiter (`createInMemoryRateLimiter`) now has a 10,000-key LRU cap with periodic expired-entry sweeping. Prevents unbounded `Map` growth under enumeration / IP-spoofing (audit M1).
  - Upstash rate-limiter outages now emit a throttled `rate_limit.degraded` audit log entry in addition to the existing `console.error`, giving operators a durable record of when the limiter was failing open (audit M2).

  **Schema**
  - `AuthConfig` now properly types `maxConcurrentSessions`, `passwordPolicy`, and `oauth.{providers, allowSelfSignup}` — these were already consumed at runtime but cast through `any`.
  - `ActuateCMSConfig.redirects.allowedExternalHosts` is now part of the public type.

  **Bug fixes**
  - Removed dead `config.plugins.forms.hooks` access in the form-submission handler — `plugins` is an array, not an object keyed by name, so this path always evaluated to `undefined` and was masked by the `as any` cast.
  - `/api/cms/health` now derives the response status from env validation in **both** the success and the DB-failure paths (Bugbot review on PR #41, Medium). The early-return on `db()` failure previously hardcoded `status: 'degraded'`, so a deploy with a broken DB _and_ a malformed `CMS_SECRET` would report `degraded` instead of the more accurate `unhealthy` — defeating the M6 goal of catching env misconfig at `/health` time.
  - `validateEnvShape()` "ok" messages no longer disclose exact secret lengths (Bugbot review on PR #41, Low). The previous wording — `Configured (42 chars).` — leaked configured `CMS_SECRET` and `CRON_SECRET` lengths via the unauthenticated `/health` endpoint, narrowing brute-force search space with no operational benefit. Lengths still appear in error/warn messages because those only surface on misconfiguration that the operator must fix.
  - Removed dead exported `log` singleton from `diagnostics/logger.ts` (Bugbot review on PR #41, Low). Every consumer uses `createLogger(scope)`; the convenience export was never imported.
  - Removed unreachable `isPlaceholder(encKey)` branch in `validateEnvShape()` (Bugbot review on PR #41, Low). After the `length === 64` and `HEX_RE` checks pass, the value is guaranteed pure hex characters, but every pattern in `PLACEHOLDER_PATTERNS` requires non-hex letters — so the branch could never fire. The `HEX_RE` failure message already reads "Looks like a placeholder" verbally, which covers the common case (e.g. `aes256-local-dev-key-change-in-prod`).
  - `/api/cms/health` now consults all three legitimate `CMS_SECRET` sources via a wrapped `EnvSource` passed to `validateEnvShape()` — `process.env.CMS_SECRET`, the legacy `process.env.CMS_SESSION_SECRET`, and `getActuateConfig()?.secret` (Bugbot review on PR #43, Medium). Previously the validator only inspected `process.env.CMS_SECRET`, so a deploy that configured the secret via `actuate.config.ts` got the contradictory response `secretConfigured: true` AND `status: "unhealthy"` — false alarm that would trip Kubernetes readiness probes / load-balancer health checks. The validator's `'missing'` message now also names the config-file path so the diagnostic matches `getSessionSecret()`'s own behavior.

  **Documentation**
  - `README.md`: filled in with project overview, repo layout, dev quickstart, Windows long-path setup instructions, env-var reference, and release flow.
  - `.cursor/rules/security.mdc`: corrected to say `sanitize-html` (matches code) instead of `isomorphic-dompurify`.
  - `.cursor/rules/architecture.mdc`: documents the `getActuateConfig()` accessor and forbids direct `globalThis` reads.

  **Tests** (+ 80 net new tests; 525 total passing across cms-core)
  - `__tests__/auth/oauth.test.ts` (29): PKCE primitives, state JWT round-trip, nonce binding, provider mismatch rejection, password-protected account refusal, self-signup gating, OAuthAccount-based linking, session creation.
  - `__tests__/auth/reset.test.ts` (17): single-use tokens, expiry, deactivated-user rejection, password policy enforcement, atomic update + session revocation, replay protection.
  - `__tests__/diagnostics/env.test.ts` (12): shape validation across every required and optional env var, plus 3 parametrized regression cases asserting "ok" messages do not include exact lengths for `CMS_SECRET`, `CMS_ENCRYPTION_KEY`, or `CRON_SECRET`.
  - `__tests__/diagnostics/logger.test.ts` (7): level filtering, JSON output, prefix formatting.
  - `__tests__/security/rate-limit.test.ts` (+2): LRU cap eviction and most-recently-used preservation.
  - `__tests__/api/health.test.ts` (5): `/health` returns `unhealthy` (not `degraded`) when env is broken AND db throws; `/health` returns `degraded` when only db throws and env is fine; `/health` JSON contains no `\d+\s*chars?` pattern (length-leak regression); `/health` does not report `unhealthy` when the secret is supplied via `CMS_SESSION_SECRET` (legacy env); `/health` does not report `unhealthy` when the secret is supplied via `actuate.config.ts → secret` (config-file path).

  **Internal**
  - `turbo.json`: explicit `outputs: []` for `@actuate-media/update-server#build` (it runs `tsc --noEmit`, no artifacts).
  - Migrated `console.*` calls in `rate-limit`, `audit`, and `oauth` to the new structured logger.
  - Replaced `@ts-ignore` with `@ts-expect-error` on the streaming `RequestInit` cast.

## 0.12.0

### Minor Changes

- 370fa95: Security hardening (engineering-audit High items):
  - **SSRF DNS-rebinding defense**: `safeFetch` now resolves the hostname before fetching and rejects when any A/AAAA record lands in a private range. Previously the URL was only validated as written, so an attacker controlling DNS for `evil.tld` could return a public IP at validation time and `127.0.0.1` at fetch time.
  - **SSRF IP canonicalization rewrite**: replaced regex-based hostname matching in `validateWebhookUrl` with a proper IP canonicalizer (`security/ip-canon.ts`). Now blocks every encoding `getaddrinfo` accepts — decimal (`http://2130706433`), octal (`http://0177.0.0.1`), hex (`http://0x7f.0.0.1`), short-form (`http://127.1`), IPv4-mapped IPv6 (`http://[::ffff:127.0.0.1]`), bracketed IPv6, and the carrier-grade NAT range (`100.64.0.0/10`). Cloud-metadata hostnames (`metadata.google.internal`, `*.internal`) are also rejected explicitly.
  - **AES-256 key validation**: `encryptField` / `decryptField` now throw `InvalidEncryptionKeyError` at the call site if `CMS_ENCRYPTION_KEY` isn't exactly 64 hex chars. Previously a misconfigured key (e.g. the placeholder `aes256-local-dev-key-change-in-prod`) only surfaced as an opaque WebCrypto error the first time a field was written — late, in production, hard to diagnose.
  - **JWT payload shape validation**: `verifySession` and OAuth `verifyState` now assert the decoded payload has the expected fields (`userId`, `role`, `sessionId`) and strip extra attacker-supplied claims before returning. Throws `InvalidSessionPayloadError` / `InvalidOAuthStateError` on malformed payloads.
  - **Login user-enumeration timing defense**: when the email doesn't exist OR the account is OAuth-only, the login handler now still runs PBKDF2 against a dummy hash before returning the error. Previously the response-time delta let attackers enumerate valid emails.
  - **PBKDF2 strength bump**: hash iterations raised from 100,000 → 600,000 (OWASP / NIST 2023+ guidance for PBKDF2-HMAC-SHA256). The hash format embeds the iteration count, so old 100k hashes still verify; `verifyPassword` reads the count from the stored string. Login opportunistically re-hashes weak stored hashes after a successful sign-in.
  - **Cron endpoints**: added `/api/cms/cron/publish`, `/api/cms/cron/cleanup`, `/api/cms/cron/seo-scan` matching the documented Vercel cron paths. Auth is via `Authorization: Bearer ${CRON_SECRET}` (constant-time compare, fail-closed when the env var is unset). Cleanup hard-deletes expired sessions, old audit logs (90d), trashed documents (30d), and used password reset tokens (1d).

  New tests: 99 added across `auth/password`, `auth/session`, `security/encrypted-fields`, `security/ssrf`, `security/safe-fetch`, and `cron/`.

### Patch Changes

- 370fa95: Security follow-ups from the Bugbot review of PR #40 (engineering-audit High items):
  - **SSRF — malformed IP literals now fail closed.** `canonicalizeHostname()` previously returned `{isHostname: false}` for IP-shaped strings that failed to parse (e.g. `::1::1`, `300.0.0.1`) without setting `ipv4`/`ipv6`. `validateWebhookUrl` and `safeFetch.resolveAndCheck` then treated that as "valid public IP, no DNS lookup needed" and let the request through. The canonicalizer now exposes an explicit `isValidIp` flag and the SSRF callsites reject malformed IP literals up front.
  - **Login timing — dummy PBKDF2 hash is now eager.** `compareToDummyHash` previously initialized the dummy hash lazily on first call, so the first unknown-email login after a cold start was ~1s slower than subsequent ones, partially defeating the timing-side-channel defense added in the original High fix. The dummy hash is now precomputed at module load (top-level Promise) and `compareToDummyHash` awaits it.
  - **Cron auth — constant-time compare no longer leaks secret length.** `isAuthorizedCronRequest` compared the bearer token to `CRON_SECRET` after an early `if (a.length !== b.length) return false`, which made the response time depend on the submitted token's length. Replaced with HMAC-SHA256 of both inputs (always 32 bytes) compared via `node:crypto.timingSafeEqual` against a per-process random key.
  - **Cron endpoints — accept GET (Vercel Cron) in addition to POST.** Vercel Cron sends GET requests by default. The original implementation registered the cron paths as POST-only, so every Vercel-scheduled invocation would have returned 404/405 and silently skipped. Endpoints are now registered for both GET and POST so Vercel Cron and self-hosted POST schedulers (k8s CronJob, GH Actions `curl -X POST`, EventBridge) both work.
  - **Cron `modelExists` correctly rejects null delegates.** The guard used `typeof db[name] === 'object'`, which is `true` for `null` (the historical `typeof null === 'object'` quirk). With `{ session: null }` the guard returned true and the subsequent `db.session.deleteMany(...)` threw a TypeError that the outer try/catch only papered over. Now explicitly checks `delegate !== null`.
  - **Cron SEO scan H1 detection is case-insensitive.** The image-missing-alt scan uses `/gi`, but the H1-presence check used `content.includes('<h1')`, so documents with valid `<H1>` headings were falsely flagged as missing. Replaced with `/<h1\b/i.test(content)` to match the HTML spec and the rest of the function's behavior.
  - **Cron SEO scan alt-text check is case-insensitive.** Sibling bug to the H1 fix above: the `<img>` regex was `/gi` (case-insensitive), but the `alt=` substring test inside the filter was case-sensitive (`img.includes('alt=')`), so `<IMG ALT="text">` matched the regex and was then falsely counted as missing alt text. Replaced with `/\balt\s*=/i.test(img)` so all uppercase / mixed-case attribute spellings are recognized.

  New tests:
  - `security/ssrf.test.ts` — 9 cases covering malformed IPv4/IPv6 literals, embedded-null hostnames, oversize labels, and `validateWebhookUrl`/`resolveAndCheck` rejecting them.
  - `auth/password.test.ts` — regression assertion that the first `compareToDummyHash` call is not significantly slower than later calls.
  - `cron/cron.test.ts` — 5 cases covering varying-length `CRON_SECRET` attempts (constant-time guarantee), plus 1 regression for null model delegates, 4 parametrized cases for case-insensitive H1 detection, and 5 parametrized cases for case-insensitive alt-text detection.
  - `api/cron-routes.test.ts` — 12 cases covering GET (Vercel Cron path) and POST for all three cron endpoints, plus 401 paths.

## 0.11.2

### Patch Changes

- 6d2ea6e: Two fixes that emerged while stabilizing the e2e suite:
  - **cms-core**: `createRateLimiter()` now respects `ACTUATE_DISABLE_RATE_LIMIT=1` and returns a permissive limiter that always allows requests. This is for test harnesses where every test shares the same client IP and exhausts the strict per-IP buckets within the first few login attempts. Production never sets this var; the deploy guide and Vercel templates omit it. Previously the env var bypass only existed at the per-handler `checkRateLimitAsync` wrapper in `handlers.ts`, so the global API rate limit in `handler-factory.ts` (which uses `rateLimiter.check()` directly) ignored it and broke retries with HTTP 429.
  - **cms-admin**: `<SEO />` view crashed with "M.filter is not a function" the first time it rendered. The `/seo/link-health` endpoint returns `{ truncated, checked, issues: [...] }` (an object), but `useApiData<any[]>` typed it as an array and the `linkHealth = linkHealthData ?? []` coercion silently put the wrapper object into the `linkHealth` variable. Calls like `linkHealth.filter(l => l.status === 404)` then threw on every render — including when the user was on the (default) Pages tab, because Radix `<Tabs.Content>`'s children are evaluated eagerly even when the tab is unmounted. Fixed the typing and switched to `linkHealthData?.issues ?? []`.

## 0.11.1

### Patch Changes

- 22c314b: Add `ACTUATE_DISABLE_RATE_LIMIT=1` env-var escape hatch for test
  harnesses. Production never sets it; the wired bypass lives at the
  single `checkRateLimitAsync` chokepoint in `api/handlers.ts`.

  Why: the e2e Playwright suite shares a single `login:unknown` rate
  limiter bucket across all 12 specs (the dev server doesn't set
  `X-Forwarded-For`, so `getClientIp()` returns `'unknown'`). The strict
  5-attempts-per-15-min login limiter exhausts after the first three
  specs, causing every subsequent test to fail with "Too many login
  attempts." This was masked for months by an unrelated `pnpm
format:check` CI failure that gated the whole `e2e` job.

  The bypass is keyed off an explicit env var rather than `NODE_ENV ===
'test'` because some Next.js build steps run with `NODE_ENV=test` and
  we want the rate limiter exercised by unit tests that don't opt in.

## 0.11.0

### Minor Changes

- 6db988e: Major engineering quality + integration upgrades.

  **Security & reliability (cms-core)**
  - Audit log: fixed runtime crash from `orderBy: createdAt` → `timestamp`; never throws on insert; allows null `details`.
  - IPv4 + IPv6 + CIDR allowlist with canonical comparison; reject `unknown` IPs against non-empty allowlists.
  - Centralised trusted client IP extraction via `x-vercel-forwarded-for` / `x-real-ip` / `x-forwarded-for` (with explicit `trustProxy`).
  - Atomic Upstash `INCR` + `EXPIRE NX` rate limiter, fail-open with logging.
  - Encryption at rest for OAuth tokens, TOTP secrets, backup codes, and webhook secrets via `CMS_ENCRYPTION_KEY`.
  - Hardened TOTP login: `mfa_pending` JWT + per-IP/per-user rate limit + request fingerprint binding (no userId leak between steps).
  - Reauth required for TOTP setup/disable; revoke other sessions on TOTP changes.
  - OAuth state nonce bound to a cookie; `allowSelfSignup` flag; never silently link to password-protected accounts.
  - SSRF-safe `safeFetch()` for webhooks and `/seo/link-health`; concurrency cap + URL count cap; private IPs/loopback/link-local rejected.
  - Magic-byte + MIME validation + DOMPurify SVG sanitisation on `/media/upload`; tightened WebP/AVIF/GIF/SVG detectors.
  - Webhook + plugin hook dispatch moved outside the DB transaction so a rollback never fires events for a doc that no longer exists.
  - AI: per-user rate limit + token/cost budget + input length cap + balanced-brace JSON extractor + `validateTree` after every step + secret redaction in audit prompts.
  - CSRF exemption tightened to exact-prefix match on the rewritten path (no more `pathname.includes`).
  - `refreshSession`: validate session row exists & is active before re-issuing.
  - Public SEO endpoints require auth; `/llms.txt` filters PUBLISHED + non-deleted.
  - `/forms`, `/forms/:id/submissions`, `/search/global` users restricted to EDITOR+.
  - Open-redirect host allowlist for `/redirects`; folder routes validate scope on move/delete; password-reset email enumeration blocked.
  - `/script-tags/resolve` returns proper public cache headers.

  **Integration fixes (consumer feedback)**
  - **`createServerSiteClient(prisma, config)`** — new server-only Site client that reads from Prisma directly. Use in Server Components, `generateMetadata`, route loaders. Avoids the `Invalid URL` crash that `createSiteClient()` throws under bare Node, and skips the self-fetch overhead.
  - **`createSiteClient()`** now auto-resolves `baseUrl` from `NEXT_PUBLIC_SITE_URL` or `VERCEL_URL` when not explicitly provided.
  - **`/public/globals/:slug`** defaults to public when no `access.read` is set (was a 403 footgun).
  - **`cmsApi`** auto-bootstraps the `actuate_csrf` cookie before non-GET requests; `AdminRoot` also primes it on mount when a session exists. Fixes the silent 403 every consumer hit when signing in via a custom Server Action.
  - **AdminShell** routes collections with `type: 'page'` through `<PageBuilder>` instead of `<DocumentEdit>`. Built-in admin paths (`/users`, `/media`, etc.) are reserved before collection routes so a user-defined `users` collection can't shadow the Users view.
  - **`BlockCatalog`** now reads custom blocks from `globalThis.__actuateConfig.pageBuilder.blocks` automatically. Also accepts an explicit `customBlocks` option and an `includeCore: false` flag.
  - **Page Builder envelope** unified at the top level — `layout` and `pageSettings` are accepted on POST/PUT and returned at top-level on GET. Storage shape is `data._layout` / `data._pageSettings`; both wire shapes accepted on input for backwards compatibility.
  - **`/media/upload`** now uses the configured platform storage adapter (e.g. `platform-vercel`) when set; honors `token`/`prefix` options. Falls back to direct `@vercel/blob` import for legacy installs.
  - **BlockEditor** field types fleshed out: real TipTap editor for `richText`, working media picker for `media`, JSON editor with validation for `json`, plus `slug`, `date`, and an array editor for primitive arrays.
  - **`pageBuilder.previewTheme`** now applied to the canvas via scoped CSS variables.
  - OpenAPI: documented `/public/globals/:slug` and `/resolve`.

  **DX**
  - `.env.example` documents required formats for `CMS_ENCRYPTION_KEY` (64-hex), `BLOB_READ_WRITE_TOKEN`, and `RESEND_*`.
  - Tests: 266 passing (was 230); regression tests for IP allowlist, client-ip, redact, secret-storage, audit, upload-magic, server-site, custom block discovery.

## 0.10.4

### Patch Changes

- 58bf2e6: Add site-wide SEO robots defaults with per-page index/follow override controls.

## 0.10.3

### Patch Changes

- e2fabee: Add AI-friendly deployment diagnostics, align generated schemas with first-run admin features, and harden setup guidance for design-first deployments.

## 0.10.2

### Patch Changes

- b66800c: Align admin-facing API response shapes with cms-admin consumers, add Vercel Blob image configuration, and harden admin views against partial data.

## 0.10.1

### Patch Changes

- b224105: Fix post-release developer experience regressions in public site globals, scaffolded Prisma wiring, and seed/populate database initialization.

## 0.10.0

### Minor Changes

- 266b657: Add first-class public site helpers, CLI init/populate workflows, and generated frontend boilerplate for globals, document resolution, and media normalization.

## 0.9.0

### Minor Changes

- c3c55c7: Add AI-powered page generation pipeline with prompt-to-page workflow.

  **cms-core**: AI pipeline types (GenerationContext, GenerationStep, StepResult, GenerationResult), prompt template builders for structure/content/SEO/accessibility steps, deterministic accessibility auto-fix engine (auditAccessibility, fixAccessibility), and API endpoints for /page-builder/generate, /page-builder/generate-block, /page-builder/audit-a11y, and /page-builder/fix-a11y.

  **plugin-ai**: Multi-step generatePage() pipeline that runs structure → content → SEO → accessibility steps sequentially via AIProvider, with validation retry, graceful fallback, progress callbacks, and token tracking. Single-block generateBlockContent() for context-aware content generation.

  **cms-admin**: AI Generate Dialog with prompt input, tone selection, step toggles, business context fields, real-time generation progress, and step-by-step review with accept/regenerate flow. AI Block Assist button for one-click content generation per block. AI button integrated into the builder toolbar.

- 2ef3505: Add SEO analysis and design scoring for the page builder. cms-core gains `analyzeSEO()` (content extraction, readability, 15+ SEO checks, per-block hints), `analyzeDesign()` (6-category scoring for visual hierarchy, spacing consistency, content density, color usage, image quality, and mobile readiness), and semantic block intelligence with smart suggestions. cms-admin replaces the placeholder SEO and Design tabs in the page builder ContextPanel with fully functional `BuilderSEOPanel` (score ring, checks, readability grid, SERP/social previews, inline editing) and `DesignScorePanel` (score ring, category progress bars, expandable checks, severity-ranked suggestions).
- 2269f10: Add page templates and saved sections for the page builder. cms-core gains 6 built-in page templates (blank, landing, about, contact, location, blog-post) with full container tree structures, plus CRUD API endpoints for both page templates and saved sections with tree validation, auto-seeding, and audit logging. cms-admin adds a TemplatePicker modal for new page creation, a SavedSections CRUD management view with category filtering, and admin routing for the new views.

## 0.8.0

### Minor Changes

- e19430e: Add page builder foundation: container tree types, Zod validation schema, tree manipulation utilities, unified block catalog with 10 core types and named variants, and Prisma models for page templates and saved sections. New `pageBuilder` config option on `ActuateCMSConfig`. New `./page-builder` export path.

## 0.7.0

### Minor Changes

- b5de8dd: Add password reset flow for admin login. Users click "Forgot Password?" on the login page, enter their email, and receive a time-limited reset link via the configured email adapter (Resend). The reset token is hashed before storage and expires after 1 hour. On successful reset, all existing sessions are revoked.
  - New PasswordResetToken Prisma model with migration 0005_password_reset_tokens.
  - POST /auth/forgot-password and POST /auth/reset-password API endpoints with rate limiting.
  - Core auth module (reset.ts) with token generation, email dispatch, and password update logic.
  - ForgotPassword and ResetPassword admin views with full form validation.
  - AdminRoot routes /forgot-password and /reset-password in the unauthenticated shell.

## 0.6.0

### Minor Changes

- 54c7a3b: Add Script Tags management system for injecting tracking codes, analytics, and custom scripts into public site pages. Admins create tags with HTML/JS code, choose placement (head, body open, body close), set scope (entire site, parent pages + descendants, or exact URLs), and assign priority order. Includes new ScriptTag database model, CRUD + public resolve API endpoints, admin list and editor views, and updated scaffolder site layout with automatic tag rendering.

## 0.5.0

### Minor Changes

- c08cf8b: Add layout variants system for customizable per-page headers, footers, and other layout regions. Layout regions are defined in config, stored as regular collection documents, and assigned to pages with automatic parent-to-child URL path inheritance. The /resolve endpoint returns resolved layout variants alongside page data. Admin UI includes layout assignment on document edit, collection grouping in sidebar, and default layout settings.

### Patch Changes

- 80aa939: Add scaffold site rendering layer with catch-all route, block components, layout components, and critical config wiring fixes.
  - Fix API route template to pass config to handleActuateAPI (enables /resolve and layout regions)
  - Fix admin page template to pass real config to AdminRoot (enables layout sidebar and blocks field)
  - Fix /resolve endpoint to handle root path (/) by looking up "home" or "index" slug
  - Add blocks-first page fields, header/footer collections, and layout config to scaffold (conditional on blocks plugin)
  - Generate catch-all page route with dynamic SEO metadata and first-boot welcome page
  - Generate BlockRenderer, HeroBlock, CTABlock, TextBlock, ImageTextBlock components
  - Generate SiteHeader and SiteFooter layout components with graceful fallbacks

## 0.4.0

### Minor Changes

- f2cf7d7: Redesign Dashboard and Pages views: extended /stats API with statusCounts, formCount, avgSeoScore, and author names; new /seo/summary endpoint; recharts donut chart for content overview; SEO performance panel; folder color badges and SEO score column in Pages view.

## 0.3.1

### Patch Changes

- 0621037: Make Dashboard collection-aware instead of hardcoding Posts/Pages labels.

  **cms-core:**
  - `/stats` API now returns `collectionCounts` (per-collection document counts via groupBy)
  - Recent documents include `collection` field in response

  **cms-admin:**
  - Dashboard reads `config.collections` to derive stat card labels, counts, routes, and quick actions
  - "Edit" links use `/{doc.collection}/{doc.id}` instead of hardcoded `/posts/{id}`
  - "New" quick actions are generated from configured collections
  - Falls back to generic "Documents" / "Pages" labels when no config is provided

## 0.3.0

### Minor Changes

- 4e3a5eb: RC2-RC4 and hardening release.

  **cms-core:**
  - Replace isomorphic-dompurify with sanitize-html (eliminates Vercel ESM crash)
  - Remove cms-core from serverExternalPackages, move to transpilePackages
  - Add GET /api/cms/health endpoint with model probing and diagnostics
  - Harden db() Proxy with try/catch on property access and rawDb()
  - Harden safeCount/safeFindMany against Proxy throws
  - Add startup validation for missing CMS_SECRET (503 instead of 500)
  - Bypass auth on /stats when secret is missing (returns zero-data)
  - Loosen NextConfigLike types (experimental/images/webpack as unknown)
  - Implement login anomaly detection (new IP, brute force, unusual hours)
  - Implement webhook DNS resolution with private range checking
  - Implement TOTP reauth verification
  - Add Zod validation for secret in defineConfig (min 32 chars)
  - Read CMS_CORE_VERSION from package.json at runtime instead of hardcoding '0.1.0'

  **cms-admin:**
  - Ship pre-compiled CSS (dist/actuate-admin.css) with all 48 responsive classes
  - New export: @actuate-media/cms-admin/styles/precompiled.css
  - Add CSS containment (`contain: layout style`) on .actuate-admin
  - Fix ShortcutHelp modal using React state instead of DOM manipulation
  - Add fixed-position layout isolation wrapper to AdminRoot
  - Add useApiData retry limits (maxRetries=3) with exponential backoff
  - Dashboard shows health-aware "Database Setup Required" banner

  **cli:**
  - Add `actuate db:init` command (injects CMS models into Prisma schema)
  - Add `actuate db:status` command (checks model presence and DB connectivity)

  **create-actuate-cms:**
  - Embed full CMS schema (9 models + enum) in generated schema.prisma
  - Add @actuate-media/cli to scaffolded devDependencies
  - Use pre-compiled CSS import in scaffolded admin layout

## 0.2.3

### Patch Changes

- Fix /stats endpoint: use Promise.allSettled so missing Prisma models return 0 instead of 500. Dashboard now shows a soft warning instead of a red error banner when stats are unavailable. Add !important to font-family on .actuate-admin to prevent consumer site fonts from cascading into the admin panel.

## 0.2.2

### Patch Changes

- Loosen withActuateCMS types: accept minimal config objects without requiring full CollectionDefinition (labels, fields). Replace Partial<ActuateCMSConfig> with WithActuateCMSOptions and NextConfigWithActuate with NextConfigLike that aligns with Next.js's NextConfig shape. Consumers no longer need `as unknown` casts.

## 0.2.1

### Patch Changes

- Fix Turbopack conflict: remove cms-core from transpilePackages since it's already in serverExternalPackages. Turbopack forbids a package in both lists. Also add a safety filter so any overlap between the two lists is resolved in favor of serverExternalPackages.

## 0.2.0

### Minor Changes

- 682dac7: Fix consumer integration issues discovered during MaidPro.co deployment
  - **cms-core**: API handlers gracefully degrade when CMS Prisma models are missing (returns 501 instead of 500)
  - **cms-core**: Accept CMS secret via `actuate.config.ts` (`secret` field) in addition to `CMS_SECRET` env var
  - **cms-core**: `withActuateCMS` now accepts `Partial<ActuateCMSConfig>` (no more forced type casts)
  - **cms-core**: `withActuateCMS` auto-injects all `@actuate-media/*` packages into `transpilePackages`
  - **cms-core**: Expanded Prisma schema fragment with all CMS models for consumer merge
  - **cms-admin**: Removed duplicate Tailwind import from admin styles (consumers already load Tailwind)
  - **platform-vercel**: Email adapter accepts both `defaultFrom` and `from` for sender address
  - **plugin-email**: Email plugin accepts both `defaultFrom` and `from` for consistency

### Patch Changes

- 3fd38a2: Fix all 13 integration issues discovered during MaidPro.co deployment

  **cms-core:**
  - Add .js extensions to all ESM relative imports (58 files)
  - Replace workspace:\* with versioned deps in all packages
  - Add sideEffects:false for proper tree-shaking
  - Subpath exports (/config, /api, /middleware, /next) avoid barrel side effects
  - Stats/search routes use safeCount/safeFindMany for missing Prisma models
  - Ship prisma/cms-schema.prisma with all required CMS models
  - Accept both CMS_SECRET and CMS_SESSION_SECRET with clear error messages
  - Fix hardcoded /admin paths in OAuth flow (now uses getAdminPath())
  - Loosen withActuateCMS and createCMSMiddleware parameter types
  - Auto-inject transpilePackages and serverExternalPackages in withActuateCMS
  - Unify PluginDefinition with ActuatePlugin interface

  **cms-admin:**
  - Scope all 40+ CSS theme variables to .actuate-admin instead of :root
  - ThemeProvider applies dark class to .actuate-admin, not document.documentElement
  - Scaffold uses (admin)/(site) route groups for full CSS/script isolation
  - Sidebar branding supports custom logo via config.admin.branding

  **platform-vercel:**
  - Email adapter accepts both `from` and `defaultFrom`

  **All plugin/platform packages:**
  - Fix ESM .js extensions in compiled output
  - Replace workspace:\* with ^0.1.0 for publishable deps
