18.1 Media Type and Media Library
The media type stores references to files managed in a centralized media library. The library handles uploads, storage, usage tracking, and orphan detection.
Field Declaration
featuredImage:
type: media
accept: [image/jpeg, image/png, image/webp]
maxSize: 5mb
minWidth: 800
minHeight: 600
aspectRatio: "16:9"
attachments:
type: array
items:
type: media
accept: [application/pdf, .docx, .xlsx]
maxSize: 50mb
Constraints
| Key | Type | Description |
|---|---|---|
accept | string[] | Allowed MIME types or extensions. Wildcards supported: image/*. |
maxSize | string | Maximum file size with unit: 5mb, 500kb, 1gb. |
minWidth | integer | Minimum image width in pixels (images only). |
maxWidth | integer | Maximum image width in pixels. |
minHeight | integer | Minimum image height in pixels. |
maxHeight | integer | Maximum image height in pixels. |
aspectRatio | string | Required aspect ratio: "16:9", "1:1", "4:3". |
Endpoints
The media library exposes six endpoints per domain (base = the domain API
prefix, e.g. /api/v1/academy):
| Method | Path | Action | Purpose |
|---|---|---|---|
GET | {base}/media | media.list | List the library. Query: search, mime_type (e.g. image/*), sort (default -created_at), orphans (bool), limit (1–500, default 50), offset. |
POST | {base}/media/upload | media.upload | Catalogue an asset (and store its bytes when a file part is present). |
GET | {base}/media/:id | media.get | One asset plus its references[]. 404 if unknown. |
DELETE | {base}/media/:id | media.delete | Delete an asset. 409 if still referenced. |
POST | {base}/media/bulk-delete | media.bulk_delete | Body { "ids": [...] }. Deletes unreferenced ids, skips referenced ones. |
GET | {base}/media/orphans | media.orphans | Assets with zero references (safe-to-prune set). |
media.list returns a paginated envelope:
{
"data": {
"items": [ /* media rows */ ],
"meta": { "total": 42, "page": 1, "per_page": 50,
"total_pages": 1, "has_next": false, "has_prev": false }
}
}
Upload
filename and mime_type are required. Because the REST multipart parser
forwards only the file part (text form fields are dropped), pass them as query
parameters, with the binary in the request body:
POST /api/v1/{domain}/media/upload?filename=hero.png&mime_type=image/png
Content-Type: multipart/form-data
(binary file data)
Do not send
size— the handler types it as a number and a query-string value is rejected; the server derives the true size from the stored bytes.
Response (201):
{
"data": {
"id": "550e8400-e29b-41d4-a716-446655440000",
"filename": "hero.png",
"mime_type": "image/png",
"size": 245760,
"path": "/api/v1/files/2026/06/550e8400-e29b-41d4-a716-446655440000.png"
}
}
path is the public URL for the bytes, served by the file store backing
(see §18.8). It resolves only when a file store is loaded and bytes were
supplied; otherwise the row is catalogued with a placeholder
/media/{id}/{filename} path that does not serve.
Validation scope (current behaviour). media.upload itself validates only
that filename/mime_type are present (400 MISSING_FIELDS otherwise).
Byte-level limits — files.max_file_size and files.allowed_types — are
enforced by the file store (§18.8) at write time; if the store rejects the
bytes, the metadata row is still catalogued without bytes rather than failed.
The field-level accept/maxSize/dimension constraints below are checked
when the asset is assigned to a type: media field on a record
(create/update), not at upload.
Status: per-field content validation is Extension-tier — the generic create path currently skips it, so a bogus reference can be stored unvalidated. The constraint table is the intended contract.
Storage Format
A media field stores the media item’s UUID as its value, on both write and read:
{ "featuredImage": "550e8400-e29b-41d4-a716-446655440000" }
To obtain the full metadata (filename, MIME, size, path), the client resolves
the id via GET {base}/media/:id.
Status: automatic expansion of a media field into an inline metadata object in record responses is Extension-tier (not currently performed) — responses return the raw UUID string. Clients should resolve metadata explicitly, or batch via
media.list.
Array of Media
Multiple media items are stored as a JSON array of UUIDs:
gallery:
type: array
items:
type: media
accept: [image/*]
minItems: 1
maxItems: 20
{
"gallery": [
"550e8400-...",
"660f9500-...",
"770a0600-..."
]
}
Each UUID in the array is tracked individually in the references table.
Reference Tracking
The media library maintains a {domain}_media_references table that records which resources reference which media items:
| Column | Type | Description |
|---|---|---|
mediaId | uuid | The media item |
resourceName | string | The resource that references it |
recordId | uuid | The specific record |
fieldName | string | The field that holds the reference |
References are updated automatically on create and update. When a record is created with featuredImage: "550e8400-...", a reference row is inserted. When the record is updated with a different image, the old reference is removed and the new one is inserted.
Orphan Detection
The media library provides an endpoint to find unreferenced media items:
GET /api/v1/{domain}/media/orphans
Returns media items that exist in the library but are not referenced by any resource field. Orphans may be safe to delete — they are files that were uploaded but never used, or files whose referencing records have been deleted.
Safe Deletion
Deleting a media item that is still referenced returns HTTP 409:
{
"status": "error",
"code": 409,
"error_code": "MEDIA_REFERENCED",
"error_message": "Cannot delete media referenced by 3 record(s)"
}
The response reports the count of referencing records. The media item MUST be
unreferenced before it can be deleted. media.bulk_delete applies the same rule
per id — referenced ids are skipped (reported in skipped) rather than
failing the batch.
Permissions & Validation
Two independent controls govern media — keep them distinct:
1. Assignment (governed today). Setting a record’s type: media field is an
ordinary field write, so it inherits Chapter 11 field-level permissions.
Gate who may attach which asset to which record on the owning resource:
Article:
permissions:
update: [author, editor, admin]
fields:
featuredImage:
write: [editor, admin] # only editors/admins may (re)assign the cover
read: ["*"] # anyone may see which asset id is referenced
A caller without featuredImage.write has the field stripped from their
create/update; featuredImage.read controls whether the id is exposed. Reference
rows are written in the same path, so resource RBAC transitively governs what
gets referenced.
2. Library management. The media.* endpoints are gated per domain by a
media.permissions block grouping the six actions into three tiers. Tokens use
the resource-RBAC vocabulary (§11): * (public), authenticated, owner
(treated as authenticated until per-asset ownership lands), and concrete roles
(any-match):
media:
permissions:
browse: [authenticated] # media.list, media.get
upload: [author, editor, admin] # media.upload
manage: [admin] # media.delete, media.bulk_delete, media.orphans
When the block — or a tier — is omitted, each tier defaults to authenticated:
the endpoints are never anonymously accessible unless an operator opts in with
["*"]. Tighten by naming roles; a tier containing a role list requires the
caller to hold one of them (403 otherwise; 401 when unauthenticated).
| Tier | Endpoints | Default | Tighten with |
|---|---|---|---|
browse | media.list, media.get | authenticated | role list |
upload | media.upload | authenticated | role list |
manage | media.delete, media.bulk_delete, media.orphans | authenticated | e.g. [admin] |
The file-store endpoints have their own server-config gate (§18.8):
files.upload_roles (roles allowed to POST /files/upload; [] = any
authenticated) and files.serve_public (default true — set false to require
auth on GET /files/*).
Uploading only catalogues an asset; it never grants the right to attach it (that stays governed by field-level
write, above). Field-levelreadhides the id, not the bytes — for private assets setfiles.serve_public: falseor issue signed URLs.
18.2 SVG Type and Sanitization
The svg type stores inline SVG markup. Content is sanitized on write to prevent script injection and other security risks.
Field Declaration
logo:
type: svg
maxSize: 50kb
Sanitization Rules
On every write (create and update), the SVG content passes through a sanitization pipeline:
Removed elements:
| Element | Reason |
|---|---|
<script> | JavaScript execution |
<iframe> | External content embedding |
<object> | External content embedding |
<embed> | External content embedding |
<foreignObject> | Arbitrary HTML embedding |
Removed attributes:
| Attribute Pattern | Reason |
|---|---|
on* (onclick, onload, onerror, etc.) | JavaScript event handlers |
href with javascript: scheme | JavaScript execution via link |
xlink:href to external URLs | External resource loading |
Optionally removed (configurable):
| Content | Default | Reason |
|---|---|---|
XML comments (<!-- -->) | Stripped | Reduce payload size |
Metadata elements (<metadata>, <desc>, <title>) | Preserved | May contain accessibility info |
Preserved elements:
All structural SVG elements are preserved: <svg>, <g>, <path>, <rect>, <circle>, <ellipse>, <line>, <polyline>, <polygon>, <text>, <tspan>, <defs>, <use>, <clipPath>, <mask>, <pattern>, <linearGradient>, <radialGradient>, <stop>, <filter>, <image> (with inline data URIs only).
Validation
| Check | Error |
|---|---|
| Content is valid XML | 422: “Invalid SVG: malformed XML” |
Root element is <svg> | 422: “Invalid SVG: root element must be <svg>” |
Content size ≤ maxSize | 422: “SVG exceeds maximum size of 50kb” |
| Content is not empty | 422: “SVG content is required” |
Storage
Sanitized SVG is stored as a TEXT column in the database. The full SVG markup is returned in API responses:
{
"logo": "<svg viewBox=\"0 0 100 100\" xmlns=\"http://www.w3.org/2000/svg\"><circle cx=\"50\" cy=\"50\" r=\"40\" fill=\"#3B82F6\"/></svg>"
}
18.3 Blocks Type and Block Editor
The blocks type stores structured content as a JSON array of typed block objects. It enables Notion-style rich content editing with a controlled palette of block types.
Field Declaration
content:
type: blocks
required: true
maxBlocks: 200
allowedBlocks:
- paragraph
- heading
- image
- code
- quote
- list
- table
- embed
- divider
- callout
Built-In Block Types
| Block Type | Has Content | Props | Description |
|---|---|---|---|
paragraph | ✅ | — | Text paragraph |
heading | ✅ | level (1–6) | Section heading |
image | ❌ | mediaId, alt, caption | Image from media library |
code | ✅ | language, lineNumbers, filename | Code block |
quote | ✅ | citation | Block quote |
list | ✅ | type (bullet/numbered/checkbox) | Ordered or unordered list |
table | ❌ | rows, columns, data | Data table |
embed | ❌ | url, provider | External embed (YouTube, etc.) |
divider | ❌ | — | Horizontal rule |
callout | ✅ | style (info/warning/tip/danger) | Highlighted callout box |
Block JSON Structure
Each block is a JSON object with type, optional content, and optional props:
[
{
"type": "heading",
"content": "Getting Started",
"props": { "level": 2 }
},
{
"type": "paragraph",
"content": "This guide walks you through the setup process."
},
{
"type": "code",
"content": "const app = new Kalos();",
"props": { "language": "javascript", "lineNumbers": true }
},
{
"type": "image",
"props": {
"mediaId": "550e8400-...",
"alt": "Architecture diagram",
"caption": "The Kalos microkernel architecture"
}
},
{
"type": "callout",
"content": "Remember to set your JWT secret in production.",
"props": { "style": "warning" }
}
]
AllowedBlocks Constraint
The allowedBlocks array restricts which block types can appear in the content. Blocks not in the list are rejected with HTTP 422 on create and update.
If allowedBlocks is omitted, all built-in block types are allowed.
MaxBlocks Constraint
The maxBlocks constraint limits the total number of blocks in the array. Exceeding the limit produces HTTP 422.
Block Validation
Each block is validated individually:
typemust be a recognized block type (built-in or custom).typemust be in theallowedBlockslist (if specified).contentmust be present for blocks that require it.propsvalues must match the block type’s prop definitions.- Media references in block props (e.g.,
image.mediaId) are tracked in the references table.
18.4 Custom Block Definitions
Domains MAY define custom block types in the top-level blocks: block. Custom blocks extend the built-in set with domain-specific content structures.
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 }
Custom Block Declaration
| Key | Type | Required | Description |
|---|---|---|---|
label | string | yes | Human-readable name for the block toolbar. |
hasContent | boolean | no | If true, the block has a text content area. Default true. |
props | object | no | Named properties with typed field definitions. |
Prop Types
Block props use the same type system as resource fields (§7). Common prop types: string, text, integer, boolean, enum (with inline values), media, url. Component and repeater types within block props are supported for complex block structures.
Referencing Custom Blocks
Custom blocks are referenced in allowedBlocks using $ref:
content:
type: blocks
allowedBlocks:
- paragraph
- heading
- $ref: '#/blocks/Callout'
- $ref: '#/blocks/PromoCard'
Or by name directly:
allowedBlocks: [paragraph, heading, callout, promoCard]
The parser matches block names case-insensitively against both built-in and custom definitions.
Block Storage
Custom blocks are stored using the same JSON structure as built-in blocks:
{
"type": "promoCard",
"props": {
"heading": "Try Kalos Today",
"subtext": "Get started with our quick-start guide.",
"ctaText": "Read the Docs",
"ctaUrl": "https://kalos.dev/docs",
"theme": "dark"
}
}
18.5 Icon Type and Icon Sets
The icon type stores references to icons from configured icon sets.
Field Declaration
categoryIcon:
type: icon
iconSets: [lucide, custom]
Value Format
Icon values use the format set:name:
{
"categoryIcon": "lucide:cog"
}
Icon Set Configuration
Icon sets are configured in the top-level icons: block:
icons:
lucide:
source: cdn
url: "https://cdn.jsdelivr.net/npm/lucide-static@latest/icons/"
prefix: lucide
format: svg
custom:
source: local
path: ./icons/
prefix: app
format: svg
| Key | Type | Description |
|---|---|---|
source | string | cdn (external URL) or local (filesystem path). |
url | string | CDN URL for the icon set (when source is cdn). |
path | string | Local filesystem path (when source is local). |
prefix | string | Namespace prefix used in icon references. |
format | string | Icon format: svg, png, font. |
Validation
If iconSets is specified on the field, the value’s set prefix must match one of the allowed sets. An icon value with an unrecognized set returns HTTP 422.
18.6 Font Type and Font Sources
The font type stores font family references for typography configuration.
Field Declaration
headingFont:
type: font
sources: [google, system]
Value Format
{
"headingFont": "Inter"
}
Font Source Configuration
Font sources are configured in the top-level fonts: block:
fonts:
google:
source: google-fonts
preload: [Inter, Roboto, "Fira Code"]
system:
source: system
families: [Arial, Helvetica, "Times New Roman", Menlo, Monaco, monospace]
| Key | Type | Description |
|---|---|---|
source | string | google-fonts, system, or custom. |
preload | string[] | Font families to preload (for CDN sources). |
families | string[] | Available font families (for system/custom sources). |
Validation
If sources is specified on the field, the font family must be available in one of the allowed sources. An unrecognized font family returns HTTP 422.
18.7 Component and Repeater — Operational Detail
Component and repeater types are specified in §7.6. This section covers their operational behavior beyond the type definition.
Component — Nested Object
Components store a single JSON object as one database column. The nested fields are validated individually on create and update using the same constraint system as top-level fields.
shippingAddress:
type: component
object: { $ref: '#/objects/Address' }
Partial Update: PATCH requests to a component field accept a partial object. Unspecified nested fields retain their existing values:
// PATCH — only update city, keep other address fields unchanged
{
"shippingAddress": { "city": "New York" }
}
Null Component: Setting a non-required component to null clears the entire nested object:
{
"shippingAddress": null
}
Querying: Component fields are stored as JSON. Filtering and sorting on nested fields within a component requires JSON path queries, which are store-dependent. On SQLite, JSON path queries use json_extract(). On PostgreSQL, they use ->>/->> operators. Direct filtering via the standard filter[field] syntax is NOT supported for nested component fields.
Repeater — Ordered Array of Objects
Repeaters store a JSON array of objects. Each element is validated against the repeater’s field definitions.
lineItems:
type: repeater
sortable: true
minItems: 1
maxItems: 50
object:
productId: { type: uuid, required: true }
quantity: { type: integer, min: 1, required: true }
unitPrice: { type: decimal, required: true }
notes: { type: text }
Full Replacement: Update requests to a repeater field replace the entire array. There is no partial update for individual array elements — the client sends the complete array:
{
"lineItems": [
{ "productId": "abc", "quantity": 3, "unitPrice": "29.99" },
{ "productId": "def", "quantity": 1, "unitPrice": "49.99" }
]
}
Ordering: When sortable: true, each element receives an implicit _order field (integer) that records its position in the array. The UI uses this for drag-and-drop reordering. The _order field is managed by the runtime — client-provided _order values are accepted and used as the explicit order.
Validation: Each element is validated independently. If element 2 of 5 fails validation, the error response identifies the failing element by index:
{
"errors": [
{ "field": "lineItems[1].quantity", "message": "Must be at least 1", "rule": "min" }
]
}
18.8 File Store Backing
The media library catalogues assets; the file store holds their bytes. It is
a separate infrastructure plugin (handler.files) that registers a file store
with the kernel (core->register_file_store()), paralleling the record store
adapters of Chapter 5. The media library writes and reads bytes through
whatever store is registered; if none is loaded, media.upload still catalogues
metadata but no bytes are stored and the serve URL will not resolve.
Enabling
The file store is not loaded by default. Add it to core.plugins, ordered
before adl so the store is registered before the media upload action looks
it up:
{ "name": "handler.files", "path": "kalos_files.__EXT__", "enabled": true }
Configuration (files.*)
| Key | Default | Meaning |
|---|---|---|
files.root | ./uploads | Filesystem root for stored bytes (relative to the server working directory). |
files.base_url | /api/v1/files | URL prefix prepended to a stored object’s relative path to form its public URL. |
files.max_file_size | 10485760 (10 MiB) | Hard byte cap; larger uploads are rejected by the store. |
files.allowed_types | image/*,application/pdf | Comma-separated MIME allow-list; image/* wildcards are supported. |
These are enforced at store time: a file exceeding max_file_size or whose
MIME is not in allowed_types is rejected (the calling media.upload
catalogues metadata without bytes — see §18.1).
Storage layout
LocalFileStore writes to a date-bucketed path under files.root:
<files.root>/{YYYY}/{MM}/{uuid}{.ext}
and the public URL is files.base_url + that relative path, e.g.
/api/v1/files/2026/06/<uuid>.png. This is the value returned as the media
row’s path.
Endpoints
| Method | Path | Action | Auth | Notes |
|---|---|---|---|---|
POST | /api/v1/files/upload | files.upload | Authenticated (+ files.upload_roles) | Generic upload (independent of any domain). Returns the stored object’s url/path. |
GET | /api/v1/files/:id | files.serve | files.serve_public (default public) | Flat single-segment id. |
GET | /api/v1/files/:id/:filename | files.serve | files.serve_public | Id + suggested download filename. |
GET | /api/v1/files/*path | files.serve | files.serve_public | Trailing-splat route that serves the multi-segment store layout (2026/06/uuid.png). |
Two files.* config keys gate these (see §18.1): files.upload_roles (array;
[] = any authenticated user) and files.serve_public (bool, default true;
false requires auth on every GET /files/*).
Why the splat route. The REST router (Chapter 4) matches by exact segment count, so the 3-segment storage path did not match a
:idroute and bytes 404’d. A route whose last pattern segment is*namecaptures the entire remainder of the path into that parameter. The splat is matched at lowest priority — only after exact and fixed-arity parameter routes miss, and only for routes that opt into*— so it never shadows a more specific route.files.servesetsContent-Typefrom the file extension;?download=trueaddsContent-Disposition: attachment.
Privacy.
files.serveis public by default, suitable for a CMS. For private assets, gate the serve route or issue short-lived signed URLs — a field’s Chapter 11readpermission hides the id, not the bytes.
This layer is intentionally minimal (a single local-disk store). A richer backend — S3/object storage, signed URLs, or multiple named stores — would graduate the file store into its own chapter; until then it is documented here, beside its primary consumer. See also Chapter 5 (Store Adapters) for the record-store counterpart.
18.9 Schema Endpoint
The schema introspection endpoint returns full metadata for content types:
{
"fields": {
"featuredImage": {
"type": "media",
"accept": ["image/jpeg", "image/png", "image/webp"],
"maxSize": "5mb",
"minWidth": 800,
"aspectRatio": "16:9"
},
"content": {
"type": "blocks",
"maxBlocks": 200,
"allowedBlocks": ["paragraph", "heading", "image", "code", "callout"]
},
"logo": {
"type": "svg",
"maxSize": "50kb"
},
"categoryIcon": {
"type": "icon",
"iconSets": ["lucide", "custom"]
}
},
"blocks": {
"Callout": {
"label": "Callout",
"hasContent": true,
"props": {
"style": { "type": "enum", "values": ["info", "warning", "tip", "danger"], "default": "info" },
"title": { "type": "string", "maxLength": 100 }
}
}
},
"icons": {
"lucide": { "source": "cdn", "prefix": "lucide" }
}
}
A UI generator uses this data to render the correct editor for each field: file picker with MIME filter for media, block editor with constrained toolbar for blocks, code editor with preview for SVG, icon picker for icon, and font selector for font.
End of Chapter 18.
Next: Chapter 19 — Indexes