# Murit CMS Content API v1 — Full AI Integration Specification ## Database storage monitoring Workspace media totals use database aggregates across all matching media records, with one grouped query for the authorized website list. Totals still sum original-file sizes and exclude BYOS sites from the platform pool; they are not cloud-billing measurements. Public post-summary and sitemap queries omit article bodies from database reads. Response shapes, permissions, cache headers, and MCP tools are unchanged. Platform admins see CMS database storage on Overview. Authenticated GET /api/workspace includes databaseUsage only for platform admins: { status, usedBytes, limitBytes, usagePercent, checkedAt }. Status is healthy, warning (80%), critical (90%), urgent (95%), full (100%), or unavailable (null usage). It measures connected-database tables and indexes (including article text, drafts, revisions, and operational indexes); it excludes media files stored in object storage. The default budget is 5,000,000,000 bytes; operators can set CMS_DATABASE_STORAGE_LIMIT_BYTES after an infrastructure change. Refresh is manual or every 24 hours while Overview is open. This is a storage warning, not a publishing block, backup, or billing guarantee. Public Content API and MCP tools do not expose these private metrics. ## Project setup and storage Legacy media migration supports both zero-copy retention and automated ingestion into BYOS via "npm run media:migrate -- --website ". POST /api/workspace/migrate accepts optional sourceOrigin (defaults to primaryURL) for root-relative paths; preview/import return mediaPreservation { strategy: "preserve-urls", sourceOrigin, urls, hostingVerified: false, notice }. For zero-copy, frontend clients can serve paths via same-origin reverse proxy (/wp-content/uploads/:path* -> bucket) for 100% SEO preservation with zero redirects. Featured images are external Media records editable via Original public media URL; HTML images and populated Lexical uploads become editable externalImage blocks. Foreign numeric media IDs are rejected. Deleting a reference never deletes the source file. New uploads use configured storage. Public Content API and MCP response shapes stay unchanged: preserve returned URLs. For RJLS, retain public/wp-content/uploads including payload variants and adapt the deleting/rebuilding media preparation step before retiring Payload. General offers default 200 MB workspace Murit CMS storage or optional BYOS. Next opens review with optional webhook, SEO and localization settings. Only the final create action creates the site key. Provider guides: /docs/storage. Cloudflare R2 is recommended; S3 and GCS are supported, Azure Blob is not. Provider allowances and billing apply to BYOS; switching storage does not migrate existing files. ## 1. Environment Configuration CMS operators must deploy the registered 20260909_122610_legacy_redirects migration before using legacy URL fields. It covers posts, saved versions, redirect arrays, and nullable blogBasePath. Payload runs registered prodMigrations on production initialization; frontend clients do not migrate the CMS database. See docs/DEPLOYMENT.md for upgrade verification. ```env CMS_BASE_URL="https://www.murit.space" # Deployed Murit CMS origin CMS_SITE_KEY="site_your_registered_site_key" ``` ## 2. Model Context Protocol (MCP) Stdio Integration Developers using Cursor, Claude Desktop, Windsurf, or Antigravity can interact with Murit CMS directly via MCP. ### Cursor / Claude Desktop configuration: ```json { "mcpServers": { "murit-cms": { "command": "npx", "args": ["tsx", "scripts/mcp-server.ts"], "env": { "CMS_BASE_URL": "https://www.murit.space", "CMS_SITE_KEY": "site_your_registered_site_key" } } } } ``` Available MCP Tools: - `verify_connectivity`: Check connectivity to the Murit CMS server and verify the configured site key. - `get_site_info`: Retrieve public metadata and configuration for a registered Murit CMS website. - `list_posts`: List published post summaries with pagination and optional category/tag filters. - `get_post_by_slug`: Retrieve a single published post by slug (supports automatic resolution of legacy & redirect URLs), including full pre-rendered sanitized HTML. - `get_sitemap`: Fetch all published post URLs and last-modified dates for SEO sitemap generation. - `get_legacy_posts`: Fetch all published posts as an Astro/flat legacy feed with preserved URLs, redirects, and rich HTML. Images may remain on their original host; retain those URLs unchanged. This tool does not copy or verify media. ## 3. TypeScript Interfaces ```typescript export interface MuritMedia { url: string | null; alt: string; caption: string | null; width: number | null; height: number | null; } export interface MuritTaxonomy { name: string; slug: string; } export interface MuritPostMeta { title: string; description: string | null; socialImage: MuritMedia | null; canonicalURL: string; keywords: string | null; } export interface MuritPostSummary { id: string; slug: string; title: string; excerpt: string | null; publishedAt: string; modifiedAt: string; featuredMedia: MuritMedia | null; categories: MuritTaxonomy[]; tags: MuritTaxonomy[]; seo: MuritPostMeta; redirectUrls?: string[]; legacyUrl?: string; } export interface MuritPostDetail extends MuritPostSummary { contentHtml: string; // Sanitized HTML from the Lexical editor (embeds: YouTube/Vimeo, Social Embeds for X/Instagram/LinkedIn/Facebook with responsive 50% compact sizing, native/uploaded videos, Also Read cards, checklists, code blocks as
 with HTML-escaped code, inline images, and .content-columns--* grids pre-rendered). Resized media keep their saved percentage: images via inline style="width:{n}%" and videos via a .content-video-wrap wrapper; full width emits no width. Images may include 
and a wrapping link; imported images may also carry content-image--left/right/center styling with a float/text-align style and a percentage width; captioned YouTube/Vimeo embeds are wrapped in figure.content-embed-figure; imported markup with no native block arrives as allowlist-sanitized HTML inside div.content-legacy-html. Links keep SEO rel tokens nofollow/sponsored/ugc (Lexical link fields.rel: string[]), emitted in that order and followed by noopener noreferrer for target="_blank" links. } export interface MuritPaginationMeta { limit: number; nextCursor: string | null; } export interface MuritListResponse { data: T[]; meta: MuritPaginationMeta; } export interface MuritSingleResponse { data: T; } export interface MuritSitemapItem { url: string; lastModified: string; } // Shape returned by ?format=astro, /legacy-posts, and /astro-posts. export interface AstroPost { id: string; // slug, or the numeric id when no slug exists title: string; date: string; // publishedAt excerpt: string | null; image: string | null; imageAlt: string; imageWidth: number | null; imageHeight: number | null; seoImage: string | null; seoImageWidth: number | null; seoImageHeight: number | null; categories: string[]; // category names tags: string[]; // tag names seoKeywords: string | null; seoTitle: string; seoDescription: string | null; contentHtml: string; contentMarkdown: string; updatedAt: string; lastUpdatedAt: string | null; redirectUrls?: string[]; legacyUrl?: string; } ``` ## 4. REST Endpoints & Contracts ### Cross-Origin Resource Sharing (CORS) & Preflight All public Content API endpoints (`/api/content/v1/sites/{siteKey}/*`), media routes (`/api/media/file/{filename}`), and compatibility endpoints (`/rjls-api/posts`) support CORS: - `Access-Control-Allow-Origin: *` - `Access-Control-Allow-Methods: GET, OPTIONS` - `Access-Control-Allow-Headers: Content-Type, Authorization, x-cms-site-key` - Preflight `OPTIONS` requests return 204 No Content with `Cache-Control: public, max-age=86400`. ### GET /api/content/v1/sites/{siteKey}/posts - Query Parameters: - limit: integer (1 to 50, default 20) - cursor: string (opaque nextCursor from previous response) - category: string (category slug) - tag: string (tag slug) - format: "default" | "astro" - Response: MuritListResponse, or { posts: AstroPost[] } when format=astro. ### GET /api/content/v1/sites/{siteKey}/posts/{slug} - Query Parameters: - format: "default" | "astro" - Response: MuritSingleResponse, or { post: AstroPost } when format=astro. - Automatic 308 Redirect: If queried by a legacyUrl or redirectUrls entry, automatically returns HTTP 308 Permanent Redirect with Location header and JSON body { redirect: { destination, location, permanent: true } }. - Note: Returns HTTP 404 for unknown, suspended, draft, or cross-site posts. ### GET /api/content/v1/sites/{siteKey}/legacy-posts - Dedicated JSON feed optimized for Astro SSG and legacy site migration. - Response: { posts: AstroPost[] } - Note: Preserves exact casing, hyphens, canonical URLs with root-level '/' base path, legacyUrl, and redirectUrls. Also available as /astro-posts and as /posts?format=legacy. ### GET /rjls-api/posts?siteKey={siteKey} - Backward-compatibility alias for the pre-migration RJLS frontend. Requires an explicit, registered siteKey. It returns { posts: AstroPost[] } for that website only and never falls back to another tenant. ### GET /api/content/v1/sites/{siteKey}/sitemap - Query Parameters: - limit: integer (1 to 500) - cursor: string - Response: MuritListResponse ### GET /api/content/v1/sites/{siteKey}/preview?token={signedToken} - Private preview endpoint for unpublished saved drafts. - Returns MuritSingleResponse with Cache-Control: private, no-store. ### POST /api/workspace/migrate - Authenticated endpoint for bulk legacy blog migration. Fully supported platforms: WordPress (block and classic editor), Shopify, and Payload CMS (standard Payload 3 and custom RJLS endpoints), plus Murit/Payload JSON exports; other JSON feeds are best-effort. - Actions: 'preview' (dry-run inspection), 'import' (convert HTML to Lexical, sync taxonomies, attach media, save articles) or 'resolve-links' ({ postIds } -> { linked, updatedPosts }: links Also Read blocks to articles imported in later batches). - Sources ('source', inferred when omitted): 'wordpress' { siteURL | endpointURL, page } reads 25 published posts per page with _embed (featured image, category/tag names, Yoast title/description/og_image) and returns pagination { page, totalPages, total }; 'shopify-admin' { shopDomain (*.myshopify.com), accessToken (Admin API, read_content scope, never stored), cursor } reads every published article via the Admin GraphQL API (2026-10) including global.title_tag/description_tag SEO metafields and returns pagination { nextCursor }; 'shopify-feed' { feedURL } reads the public /blogs/{blog}.atom feed (recent articles only, no SEO fields); 'payload' { siteURL | payloadSiteURL | endpointURL, page } reads 100 published posts per page (probing standard /api/posts and custom RJLS /rjls-api/posts) and returns pagination { page, totalPages, total }; 'json-url' { endpointURL }; 'json' { posts }. Uploaded WordPress REST post objects, Shopify REST articles.json, and Payload collection / export JSON objects are detected automatically. - Remote fetches only reach public HTTP(S) destinations; redirects are followed manually (max 4) and every hop is re-validated, and request headers are only sent to the original origin. - Element-accurate HTML conversion: rendered HTML is parsed as a document tree (parse5) so whole elements are recognized: images incl. captions, links, lazy-load data-src and protocol-relative URLs -> externalImage { url, alt, caption?, link?, newTab? }; YouTube (stored as canonical watch URLs, platform "youtube") / Vimeo (privacy hash kept) / MP4-WebM -> video { url, platform, startSeconds?, caption? }; Spotify / SoundCloud / Google Maps / TikTok / CodePen -> embed { provider, url, caption? }; notices / alerts / tips / callout cards -> callout { variant, title?, body }; button groups -> buttons { alignment, buttons[]{label,url,variant,newTab} }; details/FAQ/accordion groups -> faq { title?, items[]{question, answer} }; galleries -> gallery { columns, images[]{url|image, alt, caption?} }; simple tables -> table { caption?, headerRow, rows[]{cells[]{value}} }; twitter-tweet / instagram-media blockquotes and LinkedIn/Facebook iframes -> exactly one xEmbed; Gutenberg wp-block-columns (flex-basis -> layout or customWidths) and wp-block-media-text -> columns; separators -> horizontalrule; lists (nested) and quotes (with cite) keep structure;
/
 -> code { code, language } (language from language-x/lang-x classes, lang/data-lang/data-enlighter-language attributes, hljs classes or SyntaxHighlighter brush:, mapped to a Monaco key such as javascript, typescript, shell, python, php, json, yaml, sql; unknown -> plaintext with a report warning); inline YouTube links stay links;  tokens nofollow, sponsored and ugc are kept on the link node as fields.rel (other tokens such as noopener are dropped and re-derived from target). Tables, galleries, buttons, forms, unknown wp-block-* widgets and other iframes -> legacyHtml { html (allowlist-sanitized), sourceLabel } only when no safe native conversion applies (complex/linked tables, mixed-content galleries, prose-wrapped button groups, etc.). Empty WordPress paragraphs and spacers are dropped; emoji images become their characters.
- Migration report: preview/import responses include report { articles, cleanArticles, elements { images, videos, embeds, callouts, buttons, faqs, galleries, tables, socialEmbeds, alsoRead, columns, horizontalRules, legacyHtml }, preserved { [label]: count }, needsReview [{ title, slug, postId?, preserved, warnings }] } and skippedCount; import also returns postIds. A fidelity check compares every visible source word and media URL with the converted article and adds a warning when anything did not carry over; changed slugs (slugs are ASCII-only) are also reported, and posts with fully non-Latin slugs and titles fall back to post-{sourceId}.
- Migration limits: importing creates a copy in Murit CMS and never modifies the source post. Conversion is best-effort: only markup with a reliable structural, platform-class, or schema.org signal becomes a native block; everything else is preserved in an allowlist-sanitized legacyHtml block and flagged for review. Detection can be wrong, so review the full article before publishing and keep a source backup; re-publishing an imported post without review may surface an incorrect or unusual result. Inline images stay hotlinked until media ingestion runs.
- Block nodes are always stored as Lexical version 2. Payload reads version-1 block nodes as legacy { fields: { data } } wrappers, so the shared normalizer upgrades stored version-1 blocks on every read and save (unwrapping genuine fields.data payloads).
- Entity Sanitization & Decoding: Automatically decodes named, decimal, and hex HTML entities (such as ' -> ', " -> ", & -> &, “/” -> “/”) across titles, excerpts, Lexical AST text nodes, categories, tags, and media alt attributes. Existing collections can also be batch-normalized via 'npm run repair:entities'.
- Native Rich Block Conversion: Automatically converts legacy 'Also Read:'/'Read Also:' paragraphs and