Headless CMS
A content platform with a media library, block-editor articles, singleton site settings, scheduled publishing, SVG sanitization, and orphan-media detection.
- Resources
- MediaItem, Category, Article, SiteSettings
- Roles
- admin, editor, author, viewer
- Features (5)
- soft_deleteauditpermissionsmedia_librarysingleton
Headless CMS domain demonstrating media library, SVG sanitization, block editor content, custom block types, singleton resources, icon/font/color types, scheduled publishing, and media reference tracking with orphan detection.
Resources
MediaItem
Represents a single uploaded file in the domain media library. Created via
POST /media/upload. Deletion is blocked (409 CONFLICT) if the item is
referenced by any other resource.
| Field | Type | Notes |
|---|---|---|
| id | uuid | Primary key |
| filename | string | Required, immutable, max 255 |
| mimeType | string | Required, immutable, max 100 |
| size | integer | Required, immutable (bytes) |
| width | integer | Image width in pixels |
| height | integer | Image height in pixels |
| altText | string | Max 255 |
| caption | text | |
| folder | string | Default ”/”, max 255 |
| uploadedById | string | Immutable, max 100 |
| createdAt | datetime | Auto-set on create |
| updatedAt | datetime | Auto-set on update |
Custom API path: /media
Features: search
Category
Article taxonomy with icon and color. Admin-managed vocabulary with caching.
| Field | Type | Notes |
|---|---|---|
| id | uuid | Primary key |
| name | string | Required, unique, max 100 |
| slug | slug | Auto-generated from name, unique |
| description | text | Max 500 |
| icon | icon | e.g. “lucide:tag” |
| color | color | |
| createdAt | datetime | Auto-set on create |
| updatedAt | datetime | Auto-set on update |
Features: caching (600s TTL)
Article
Primary content resource. Rich block editor body supporting both built-in blocks (paragraph, heading, image, code, quote, list, table, embed, divider) and custom blocks (Callout, PromoCard).
| Field | Type | Notes |
|---|---|---|
| id | uuid | Primary key |
| title | string | Required, max 200 |
| slug | slug | Auto-generated from title, unique |
| excerpt | text | Max 400 |
| categoryId | uuid | FK to Category |
| authorId | string | Required, immutable, max 100 |
| authorName | string | Max 200 |
| status | enum | ArticleStatus, default “draft” |
| scheduledFor | datetime | Auto-publish date for “scheduled” status |
| featuredImage | media | JPEG, PNG, WebP; max 10mb |
| gallery | array | Up to 20 media images |
| attachments | array | Up to 10 files (PDF, DOCX, XLSX, ZIP); max 50mb each |
| body | blocks | Required, max 300 blocks |
| seo | component | SeoMeta (metaTitle, metaDescription, ogTitle, ogImage, noIndex) |
| createdAt | datetime | Auto-set on create |
| updatedAt | datetime | Auto-set on update |
| deletedAt | datetime | Soft delete marker |
Computed fields:
readingTimeMinutes(virtual) — estimated reading time based on body word count (250 wpm).
Features: soft delete, audit, versioning, search, export
Audit tracking: title, status, body, featuredImage, scheduledFor
Validation rules:
scheduledFormust be in the future (if set)scheduledForrequires status to be “scheduled”
Field-level permissions:
status— writable only by editor, adminscheduledFor— writable only by editor, adminseo— readable by authenticated; writable only by editor, admin
SiteSettings (Singleton)
Global site configuration. Only one record exists. No list, create, or delete
endpoints. Read via GET /site-settings/current, update via PATCH /site-settings/current.
| Field | Type | Notes |
|---|---|---|
| id | uuid | Primary key |
| siteName | string | Required, max 100 |
| tagline | string | Max 200 |
| baseUrl | url | |
| logo | svg | Max 100kb, sanitized on write |
| favicon | media | PNG, ICO, SVG; max 1mb |
| typography | component | Typography (headingFont, bodyFont, monoFont) |
| brandColors | component | BrandColors (primary, secondary, accent, background, text) |
| defaultSeo | component | SeoMeta |
| analyticsId | string | Max 50 |
| maintenanceMode | boolean | Default false |
| createdAt | datetime | Auto-set on create |
| updatedAt | datetime | Auto-set on update |
Singleton controls: disable_list, disable_create, disable_delete
Relationships
Article *──1 Category (Article.category belongsTo Category)
MediaItem is referenced by media fields across resources (featuredImage, gallery, attachments, ogImage, logo, favicon) but these are value references, not foreign key relationships. The engine tracks references and prevents orphan deletion.
Enums
ArticleStatus
| Value | Label | Color |
|---|---|---|
| draft | Draft | #6b7280 |
| review | In Review | #f59e0b |
| scheduled | Scheduled | #8b5cf6 |
| published | Published | #22c55e |
| archived | Archived | #ef4444 |
Custom Block Types
Callout
A highlighted content block with a style variant and optional title.
| Prop | Type | Values | Default |
|---|---|---|---|
| style | enum | info, warning, tip, danger | info |
| title | string | Max 100 |
PromoCard
A promotional card with heading, image, CTA button, and theme.
| Prop | Type | Notes | Default |
|---|---|---|---|
| heading | string | Required | |
| subtext | text | ||
| image | media | Images only | |
| ctaText | string | ”Learn More” | |
| ctaUrl | url | Required | |
| theme | enum | light, dark | light |
Shared Objects
- Timestamp —
createdAt(auto on create) andupdatedAt(auto on update) - SoftDelete —
deletedAtdatetime marker - SeoMeta — metaTitle, metaDescription, ogTitle, ogImage, noIndex
- Typography — headingFont, bodyFont, monoFont
- BrandColors — primary, secondary, accent, background, text
Roles and Permissions
| Role | Description |
|---|---|
| admin | Full system access, manages site settings, can delete articles |
| editor | Can publish and schedule articles, manages all content |
| author | Can create and edit own articles, cannot publish or manage settings |
| reader | Read-only access, demonstrates public/authenticated boundaries |
Permission matrix (summary):
| Resource | list | read | create | update | delete |
|---|---|---|---|---|---|
| MediaItem | authenticated | authenticated | authenticated | owner,editor,admin | admin |
| Category | * | * | admin | admin | admin |
| Article | * | * | authenticated | owner,editor,admin | admin |
| SiteSettings | (disabled) | * | (disabled) | admin | (disabled) |
Seed Data
The seed file (seed.yaml) provisions:
- 4 roles: admin, editor, author, reader
- 4 users: admin (Admin123!), alice (editor), bob (author), carol (reader) — all Demo123!
- 4 categories: Engineering, Design, Product, Culture — each with icon and color
- 1 SiteSettings record: “Kalos Dev Blog” with typography, brand colors, default SEO, analytics ID
- 5 articles: spanning published, review, draft, and scheduled statuses with block editor content, media references, and SEO metadata
Assets
The assets/ directory contains SVG placeholder files referenced by the seed data:
logo.svg— site logo used by SiteSettingsplaceholder-hero.svg— placeholder hero image used by articles
Usage
Point your kalosd config at this seed:
{
"adl": {
"domain_file": "seeds/cms/domain.yaml"
},
"seeder": {
"seed_file": "seeds/cms/seed.yaml"
}
} Domain definition
View raw (492 lines)Show domain.yaml
# seeds/cms/domain.yaml
#
# Headless CMS with Media Library — ADL Domain Definition
# ADL version: 0.3.1
#
# Resources: MediaItem, Category, Article, SiteSettings
# Relations: Article belongsTo Category
#
# ADL features exercised:
# Phase 1 — CRUD generation, REST route mapping, RBAC permissions
# Phase 6 — belongsTo, ?include=
# Phase 7 — filter, sort, paginate, search, count
# Phase 8 — Rich types: media, svg, blocks, icon, font, color, url, slug
# Phase 9 — $merge (Timestamp, SoftDelete), $ref objects/enums/blocks
# Phase 10 — UUID primary keys, custom indexes
# Phase 12 — Soft delete on Article, audit trail on Article
# Phase 14 — Field-level permissions (status, scheduledFor, seo)
#
# New features (first exercised in POC-04):
# media type — Featured images, gallery arrays, file attachments
# svg type — Brand logo with sanitization
# blocks type — Article body (paragraph, heading, image, code, etc.)
# custom blocks — Callout and PromoCard domain-level block types
# icon type — Category icon reference
# font type — SiteSettings typography
# singleton — SiteSettings (disable_create, disable_delete, disable_list)
# media library — Upload action, references tracking, orphan detection
# SVG sanitize — Script injection stripped on write
# scheduled pub — scheduledFor field + cross-field validation
domain:
name: cms
version: "1.0.0"
description: >
Headless CMS demonstrating media library, SVG sanitization, block editor
content, custom block types, singleton resources, icon/font/color types,
scheduled publishing, and media reference tracking with orphan detection.
api: {}
# ─── Shared objects ────────────────────────────────────────────
objects:
Timestamp:
createdAt: { type: datetime, autoNow: create }
updatedAt: { type: datetime, autoNow: update }
SoftDelete:
deletedAt: { type: datetime }
SeoMeta:
metaTitle: { type: string, maxLength: 60 }
metaDescription: { type: text, maxLength: 160 }
ogTitle: { type: string, maxLength: 60 }
ogImage: { type: media, accept: ["image/*"] }
noIndex: { type: boolean, default: false }
Typography:
headingFont: { type: font }
bodyFont: { type: font }
monoFont: { type: font }
BrandColors:
primary: { type: color }
secondary: { type: color }
accent: { type: color }
background: { type: color }
text: { type: color }
# ─── Shared enums ──────────────────────────────────────────────
enums:
ArticleStatus:
values:
draft:
label: "Draft"
description: "Work in progress, not visible to public"
color: "#6b7280"
review:
label: "In Review"
description: "Submitted for editorial review"
color: "#f59e0b"
scheduled:
label: "Scheduled"
description: "Approved, will auto-publish at scheduledFor date"
color: "#8b5cf6"
published:
label: "Published"
description: "Live and publicly visible"
color: "#22c55e"
archived:
label: "Archived"
description: "Removed from public listing"
color: "#ef4444"
# ─── Custom blocks ────────────────────────────────────────────
blocks:
Callout:
label: "Callout"
hasContent: true
props:
style:
type: enum
values: [info, warning, tip, danger]
default: info
title:
type: string
maxLength: 100
required: false
PromoCard:
label: "Promo Card"
hasContent: false
props:
heading: { type: string, required: true }
subtext: { type: text, required: false }
image: { type: media, accept: ["image/*"] }
ctaText: { type: string, default: "Learn More" }
ctaUrl: { type: url, required: true }
theme: { type: enum, values: [light, dark], default: light }
# ─── Shared properties ────────────────────────────────────────
properties:
slug:
type: slug
unique: true
maxLength: 255
# ─── Resources ─────────────────────────────────────────────────
resources:
# ╔══════════════════════════════════════════════════════════════╗
# ║ MediaItem ║
# ╚══════════════════════════════════════════════════════════════╝
MediaItem:
description: >
Represents a single uploaded file in the domain media library.
Created automatically on upload via POST /media/upload.
Deletion is blocked if the item is referenced by any other resource
(returns 409 CONFLICT).
api:
path: /media
object:
id:
type: uuid
primaryKey: true
filename:
type: string
required: true
immutable: true
maxLength: 255
mimeType:
type: string
required: true
immutable: true
maxLength: 100
size:
type: integer
required: true
immutable: true
description: "File size in bytes"
width:
type: integer
description: "Image width in pixels (images only)"
height:
type: integer
description: "Image height in pixels (images only)"
altText:
type: string
maxLength: 255
caption:
type: text
folder:
type: string
maxLength: 255
default: "/"
uploadedById:
type: string
immutable: true
maxLength: 100
$merge:
- { $ref: '#/objects/Timestamp' }
indexes:
- fields: [mimeType]
description: "Filter media by type"
- fields: [folder]
description: "Browse media by folder"
- fields: [uploadedById]
description: "List media by uploader"
features:
search: true
permissions:
list: [authenticated]
read: [authenticated]
create: [authenticated]
update: [owner, editor, admin]
delete: [admin]
# ╔══════════════════════════════════════════════════════════════╗
# ║ Category ║
# ╚══════════════════════════════════════════════════════════════╝
Category:
description: >
Article taxonomy. Demonstrates icon type and caching.
Admin-managed vocabulary.
object:
id:
type: uuid
primaryKey: true
name:
type: string
required: true
unique: true
maxLength: 100
slug:
$ref: '#/properties/slug'
from: name
description:
type: text
maxLength: 500
icon:
type: icon
description: "Icon reference string, e.g. 'lucide:tag'"
color:
type: color
$merge:
- { $ref: '#/objects/Timestamp' }
permissions:
list: ["*"]
read: ["*"]
create: [admin]
update: [admin]
delete: [admin]
features:
caching: true
cacheTtlSeconds: 600
# ╔══════════════════════════════════════════════════════════════╗
# ║ Article ║
# ╚══════════════════════════════════════════════════════════════╝
Article:
description: >
Primary content resource. Demonstrates blocks body (with custom
Callout and PromoCard blocks), media fields (featuredImage, gallery,
attachments), virtual computed readingTimeMinutes, versioning,
scheduled publishing, SEO component, soft delete, and audit.
object:
id:
type: uuid
primaryKey: true
title:
type: string
required: true
maxLength: 200
slug:
$ref: '#/properties/slug'
from: title
excerpt:
type: text
maxLength: 400
categoryId:
type: uuid
authorId:
type: string
required: true
immutable: true
maxLength: 100
authorName:
type: string
maxLength: 200
status:
$ref: '#/enums/ArticleStatus'
default: draft
scheduledFor:
type: datetime
description: "Future date for auto-publish when status is 'scheduled'"
featuredImage:
type: media
accept: ["image/jpeg", "image/png", "image/webp"]
maxSize: "10mb"
gallery:
type: array
items:
type: media
accept: ["image/*"]
maxSize: "10mb"
maxItems: 20
attachments:
type: array
items:
type: media
accept: ["application/pdf", ".docx", ".xlsx", ".zip"]
maxSize: "50mb"
maxItems: 10
body:
type: blocks
required: true
maxBlocks: 300
allowedBlocks:
- paragraph
- heading
- image
- code
- quote
- list
- table
- embed
- divider
- Callout
- PromoCard
seo:
type: component
object: { $ref: '#/objects/SeoMeta' }
$merge:
- { $ref: '#/objects/Timestamp' }
- { $ref: '#/objects/SoftDelete' }
computed:
readingTimeMinutes:
type: integer
formula: "ceil(wordCount(body) / 250)"
stored: false
description: "Virtual — estimated reading time based on body word count"
relationships:
category:
type: belongsTo
target: Category
foreignKey: categoryId
indexes:
- fields: [status]
description: "Filter articles by status"
- fields: [authorId]
description: "List articles by author"
- fields: [scheduledFor]
description: "Query scheduled articles"
- fields: [categoryId]
description: "List articles by category"
- fields: [status, scheduledFor]
description: "Composite: scheduled articles pending publish"
features:
softDelete: true
audit: true
versioning: true
search: true
export: true
audit:
track: [title, status, body, featuredImage, scheduledFor]
exclude: [updatedAt, deletedAt]
validation:
scheduledForFuture:
rule: "scheduledFor == null || scheduledFor > now()"
message: "Scheduled publish date must be in the future"
scheduledRequiresPublishStatus:
rule: "scheduledFor == null || status == 'scheduled'"
message: "Set status to 'scheduled' when providing a scheduled date"
permissions:
list: ["*"]
read: ["*"]
create: [authenticated]
update: [owner, editor, admin]
delete: [admin]
fields:
status:
read: ["*"]
write: [editor, admin]
scheduledFor:
read: ["*"]
write: [editor, admin]
seo:
read: [authenticated]
write: [editor, admin]
# ╔══════════════════════════════════════════════════════════════╗
# ║ SiteSettings (Singleton) ║
# ╚══════════════════════════════════════════════════════════════╝
SiteSettings:
description: >
Global site configuration. Singleton resource — one record, no list
or create. Read by anyone via GET /site-settings/current, written
by admin only via PATCH /site-settings/current.
object:
id:
type: uuid
primaryKey: true
siteName:
type: string
required: true
maxLength: 100
tagline:
type: string
maxLength: 200
baseUrl:
type: url
logo:
type: svg
maxSize: "100kb"
sanitize: true
favicon:
type: media
accept: ["image/png", "image/x-icon", "image/svg+xml"]
maxSize: "1mb"
typography:
type: component
object: { $ref: '#/objects/Typography' }
brandColors:
type: component
object: { $ref: '#/objects/BrandColors' }
defaultSeo:
type: component
object: { $ref: '#/objects/SeoMeta' }
analyticsId:
type: string
maxLength: 50
maintenanceMode:
type: boolean
default: false
$merge:
- { $ref: '#/objects/Timestamp' }
api:
path: /site-settings
disable_list: true
disable_create: true
disable_delete: true
custom_operations:
- name: current
method: GET
path: /current
permission: ["*"]
permissions:
read: ["*"]
update: [admin]