Beta . Ortha CMS is in public beta. Every plan is free until it ends.
Features Ortha CMS on GitHub

Validation

Content plugin @orthacms/content-server@orthacms/content-admin

One set of rules, applied by the server and mirrored by the admin form.

Documents 0.13.1 Updated Edit this page Report a problem

On this page

One rule set, in the framework-free @orthacms/content-domain kernel. The server applies it on every write and is the authority; the admin form applies the same rules so authors see problems early. Skipping the form does not skip the rules.

When it runs

MomentNon-publishable typePublishable type
Save (create / update)Every rule; required enforcedOnly database checks: targets exist, media accept, one-to-one not taken
Publish—Every rule, against the stored row, plus required many-relations

A draft may be incomplete or even invalid — too long, pattern mismatch — and still save. See the publish gate.

The error shape

A 422 with every issue found:

curl -X POST https://cms.example.com/api/v1/content/author \
  -H 'Authorization: Bearer orthacms_…' \
  -H 'Content-Type: application/json' \
  -d '{ "values": { "name": "A", "email": "not-an-email", "handle": "Ada Lovelace" } }'
{
    "message": "Entry validation failed",
    "issues": [
        { "field": "name", "message": "must be at least 2 characters" },
        { "field": "email", "message": "must match pattern ^[^@\\s]+@[^@\\s]+\\.[^@\\s]+$" },
        { "field": "handle", "message": "must match pattern ^[a-z0-9]+(?:-[a-z0-9]+)*$" }
    ]
}
  • Messages are sentence fragments — render them after the field label.
  • A failed publish has the same shape. Value issues come first; required many-relations are counted only once the values pass.

GraphQL answers HTTP 200 and carries the same facts in the error:

{
    "errors": [{
        "message": "Entry validation failed",
        "path": ["createAuthor"],
        "extensions": {
            "code": "VALIDATION_FAILED",
            "status": 422,
            "issues": [{ "field": "name", "message": "must be at least 2 characters" }]
        }
    }],
    "data": null
}

Per-type rules and messages

TypeCheckedMessages
anyrequiredis required
textlength, patternmust be at least 3 characters, must be at most 200 characters, must match pattern …, must be a string
richtexttext length, structurelength messages, must be a rich-text document, structural findings
numbermin, max, integermust be a number, must be a finite number, must be a whole number, must be ≥ 0, must be ≤ 5
moneyas number, always wholemust be a whole number for 19.99
booleantypemust be true or false
dateshape and calendarmust be an ISO date (YYYY-MM-DD), must be a real calendar date
datetimeISO-8601 with a timemust be an ISO date-time
selectmembershipmust be one of: standard, wide, full_bleed
multiselectevery membermust be a subset of: general, developers
jsonvalid JSON—
relationuuid; target visible from this workspace; same locale if localizedmust be an entry id, must reference an existing entry, must reference a "de" entry — …
mediashape, existence, accept, alt textsee below

Worth knowing:

  • Dates exist. 2025-02-30 matches the shape and is rejected.
  • Datetimes carry a time. 2026-10-01 is rejected, not read as midnight UTC.
  • Money is minor units. 1999 is 19.99. There is no currency on the field; a three-decimal currency is stored in its own minor unit.
  • Relation targets may be own records or published records of a shared workspace the workspace reads the type from.

Media messages

ProblemMessage
Not an id / { id, alt, decorative }must be a media asset id (multiple: must be an array of media asset ids)
alt not a stringalt text must be a string
alt over 1,000 charactersalt text must be at most 1000 characters
Non-blank alt and decorative: truecannot be marked decorative and carry alt text
Required field, asset without alt or decorativeneeds alt text, or to be marked decorative, before it can be published
Asset missing or in another workspacemust reference an existing asset
Visible asset, wrong kind/typemust be an allowed asset (image)

Missing and invisible targets — entries or assets — always get the same message, so a caller cannot probe ids it cannot see.

What “empty” means

null, undefined, a whitespace-only string, or []. Empty trips required or is skipped; it is never coerced.

Text length counts characters

minLength / maxLength count grapheme clusters: an emoji, a decomposed é, a joined family emoji — each is one.

Unknown field names are dropped, not rejected

A key in values that is not a declared field is ignored: not stored, no error. That keeps old revision snapshots restorable after a field is removed — and makes a typo silent.

A typo in a field name is silent — and the admin PATCH replaces

{"values": {"titel": "…"}} returns success and stores nothing. The admin PATCH /api/content/:type/:id replaces the whole values bag (a declared field you omit becomes null), so the same typo there also clears title. The public PATCH /api/v1/..., GraphQL update… and MCP content_update merge over stored values instead. Read the response back.

Rejected, because there the caller expects something back:

  • ?fields= on the public API — an unknown name is a 400.
  • The copilot’s write tools.

Regular expressions

pattern goes through a guarded compiler:

PatternResult
Does not compileFails at boot (define time)
Prone to catastrophic backtracking (^(a+)+$), or over 1,000 charactersEvery non-empty value fails with has an unsafe pattern rule

So a bad pattern shows up on the first save, not as a hung server.