# DynapiCMS GraphQL API reference

Field-by-field reference for the tenant CMS GraphQL API at `POST /cms/graphql`.
Source of truth: `internal/graphql/site_schema.go`, `forms_schema.go`,
`schema_builder.go`, `filter_types.go`, `upload_mutations.go` (backend) and
`admin/src/services/site-operations.ts` (a frontend that compiles against this
schema). If anything here disagrees with live introspection, introspection wins.

All examples are shown as raw GraphQL. The HTTP request body is JSON with the
document in the `query` field:

```json
{ "query": "{ siteRoutes { path } }", "variables": {} }
```

## 1. Auth & transport

Authentication options on `/cms/graphql` (in the order the middleware checks
them):

1. `Authorization: Bearer <jwt>` (or `Authorization: Token <jwt>`) — checked
   first; a valid bearer short-circuits before the API-key path.
2. `X-API-Key: <dca_...>` header.
3. `?api_key=<key>` query param — works, but leaks the key into access logs;
   never use it.
4. `auth_token` cookie (browser sessions).

Rules for API-key callers:

- Do NOT send `Authorization` together with `X-API-Key`. The Authorization
  header is evaluated first — a stale/invalid bearer makes the whole request
  401 instead of falling through to the key.
- Required key power: `site:manage` + the `render_service` plan feature for
  every `site*` field AND for flows/actions (one capability for
  the whole site add-on); `data:write` for entity-data mutations;
  `media:write` for uploads. The capability scope vocabulary is:
  `schema:manage`, `users:manage`, `settings:manage`, `plugins:manage`,
  `data:write`, `media:write`, `site:manage`.
- Errors are machine codes in `errors[0].message` (e.g. `SITE_QUERY_IN_USE`),
  with context in `errors[0].extensions`. HTTP-level quota rejections are a
  429 JSON body: `{"error": "LIMIT_API_READS" | "LIMIT_API_WRITES", "limit":
  "max_api_reads_per_hour", "retry_after": <seconds>}`.
- Quota exemption: a GraphQL document whose root fields are ALL `site*` fields
  skips hourly API quota metering entirely. One non-site root field in the
  document makes the whole request count. Keep site ops in dedicated requests.
- Shell convention for scripted callers: export `DYNAPI_APIKEY="dca_…"`
  once per session and send every request to `https://dynapi.ru/cms/graphql`
  — the ONE endpoint, no path is ever appended — as
  `curl -fsS https://dynapi.ru/cms/graphql -H "Content-Type: application/json"
  -H "X-API-Key: $DYNAPI_APIKEY" -d '{"query":"…"}'`. The flowctl CLI reads
  the same variable and has the endpoint built in (see its section below),
  so CLI and curl share one configuration. Remember env vars are
  per-process: a one-shot script invocation (`powershell -File x.ps1`,
  `sh -c`, a new terminal) does not inherit them — set them inside that
  context or pass them explicitly.

### Plain request

```graphql
query { siteRoutes { path methods } }
```

Send as:

```
POST /cms/graphql
Content-Type: application/json
X-API-Key: dca_...

{"query":"query { siteRoutes { path methods } }"}
```

### Multipart requests (uploads)

Mutations that take an `Upload!` variable (`uploadMedia`, `uploadMediaBatch`,
`uploadSiteTemplate`, `uploadSiteStatic`) use the
graphql-multipart-request-spec: a `multipart/form-data` POST with three kinds
of form parts:

- `operations` — the JSON request body; every upload variable is `null`.
- `map` — JSON mapping each file part name to the variable path(s) it fills.
  Paths MUST start with `variables.`.
- one part per file, named by the key used in `map`.

Copy-pasteable curl (media upload — `uploadMedia` is the ONE-CALL path: it
stores the file AND creates the media entity, so `entity→media` can reference
it immediately):

```bash
curl -X POST "https://dynapi.ru/cms/graphql" \
  -H "X-API-Key: dca_..." \
  -F 'operations={"query":"mutation ($file: Upload!) { uploadMedia(file: $file) { id name url mimeType fileSize type } }","variables":{"file":null}}' \
  -F 'map={"0":["variables.file"]}' \
  -F '0=@./hero.jpg'
```

Batch upload maps indices into the list variable:

```bash
curl -X POST "https://dynapi.ru/cms/graphql" \
  -H "X-API-Key: dca_..." \
  -F 'operations={"query":"mutation ($files: [Upload!]!) { uploadMediaBatch(files: $files) { id url } }","variables":{"files":[null,null]}}' \
  -F 'map={"0":["variables.files.0"],"1":["variables.files.1"]}' \
  -F '0=@./a.jpg' \
  -F '1=@./b.png'
```

The SAME wire shape pushes SITE FILES straight from disk — `uploadSiteTemplate`
/ `uploadSiteStatic` take `path` + `file: Upload!` + `draft: true`, and the
file BODY becomes the content. Pushing Liquid templates this way skips the
JSON-string escaping of `saveSiteTemplate` entirely:

```bash
curl -X POST "https://dynapi.ru/cms/graphql" \
  -H "X-API-Key: dca_..." \
  -F 'operations={"query":"mutation ($file: Upload!, $path: String!, $draft: Boolean) { uploadSiteTemplate(path: $path, file: $file, draft: $draft) { status } }","variables":{"file":null,"path":"pages/about.liquid","draft":true}}' \
  -F 'map={"0":["variables.file"]}' \
  -F '0=@./about.liquid'
```

(`uploadSiteStatic` for non-template assets — same shape, binary-safe. Or let
`flowctl files push` do the walking: see "flowctl CLI" below.)

(Do not set `Content-Type` manually with `-F`; curl adds the multipart
boundary.)

## 2. Site queries

All `site*` fields require: an authenticated caller with the
`site:manage` capability (owner/admin role, or a key scoped to it) AND the
`render_service` plan feature. Failure codes: `AUTHENTICATION_REQUIRED` (no
role), `ACCESS_DENIED_USER_PERMISSION` (capability missing),
`RENDER_FEATURE_UNAVAILABLE` (plan), `NO_TENANT`. The
domain-dependent fields
(`siteStaticFile`, `siteSubdomain`, `siteSubdomainAvailable`, `siteDomains`)
additionally require the control-plane domain client to be wired — always
present in SaaS multi-tenant mode.

Reads default to the PUBLISHED state (`draft: Boolean = false`); pass
`draft: true` to see pending drafts.

| Field | Args | Returns | Semantics |
|---|---|---|---|
| `siteRoutes` | `draft: Boolean = false` | `[SiteRoute!]!` | The routes document (published or draft). Empty list when none. A `draft: true` read ALSO appends deletion tombstones as entries with `deleted: true` (path-only) — filter them out before mutating. |
| `siteTemplates` | `draft: Boolean = false` | `[String!]!` | Template paths (e.g. `"layouts/base.liquid"`), not contents. |
| `siteTemplate` | `path: String!, draft: Boolean = false` | `String` | One template's Liquid source. `SITE_TEMPLATE_NOT_FOUND` on miss. |
| `siteStaticFiles` | `draft: Boolean = false` | `[String!]!` | Static asset paths; `draft: true` lists the draft namespace (`static/_draft/`) — the per-file "unpublished changes" list. |
| `siteStaticFile` | `path: String!, draft: Boolean = false` | `SiteStaticFile` | Metadata + body for one static file; `draft: true` reads the draft copy (404 `SITE_OBJECT_NOT_FOUND` when none). |
| `siteQueryLibrary` | `draft: Boolean = false` | `[SiteQueryEntry!]!` | Named GraphQL queries the renderer prefetches per route. Draft reads append `deleted: true` tombstones (name-only). |
| `sitePublishStatus` | — | `SitePublishStatus` | Last publish info; null fields before the first publish. |
| `siteSubdomain` | — | `SiteSubdomain` | Current subdomain + base domain. |
| `siteSubdomainAvailable` | `name: String!` | `SiteSubdomainAvailability` | Check a subdomain name; taken/reserved come back as `reason`, not errors. |
| `siteDomains` | — | `SiteDomainsInfo` | Custom domains + feature flag + edge IP for DNS pointing. |

Shapes:

```graphql
type SiteRoute {
  path: String!
  methods: [String!]!      # defaults to ["GET"] when omitted on input
  sitemapQuery: String     # library query enumerating this dynamic route's URLs for the sitemap
  cache: SiteRouteCache    # { defaultTtl: Int } seconds; 0 = live (also disables the page cache)
  flows: [SiteRouteFlow]   # flow bindings owning this route (see flows-guide.md §6)
  membersOnly: Boolean     # anonymous visitors are redirected to loginPath (GET and POST)
  loginPath: String        # redirect target for the members_only gate (default "/")
  memberCookie: String     # which sealed session cookie identifies the member
  updatedAt: DateTime      # server stamp; drives the per-item publish merge
  deleted: Boolean         # true = draft deletion tombstone (only path is meaningful)
}

type SiteStaticFile {
  path: String!
  size: Int!
  contentType: String!
  content: String          # only populated for text/* files; null for binary
  publicUrl: String!       # the site's public base (https://<base>) — NOT the file URL
}

type SiteQueryEntry {
  name: String!
  query: String!
  updatedAt: DateTime      # server stamp; drives the per-item publish merge
  deleted: Boolean         # true = draft deletion tombstone (only name is meaningful)
}

type SitePublishStatus {
  lastPublishedAt: DateTime  # nullable — null before first publish
  templatesCopied: Int
  staticsCopied: Int
  routesPromoted: Boolean
  queriesPromoted: Boolean
  sitemapUrls: Int
}

type SiteSubdomain { subdomain: String, baseDomain: String! }

type SiteSubdomainAvailability { available: Boolean!, reason: String }

type SiteRouteFlow {
  handle: String!          # form-handle slug whose flow runs
  methods: [String!]!      # GET|POST|PUT|DELETE (HEAD=GET, OPTIONS=preflight — not bindable)
  headerName: String       # optional: binding fires only when this header is present
  headerValue: String      # optional: ...and equals this value; empty = presence is enough
}

type SiteDomainsInfo {
  domains: [SiteDomain!]!
  featureEnabled: Boolean!
  edgeIp: String!           # A record target for custom domains
}

type SiteDomain {
  id: ID!
  domain: String!
  status: String!           # e.g. "pending", "active"
  isPrimary: Boolean!
  createdAt: String
  verifyToken: String       # TXT record value while pending
}
```

Minimal examples:

```graphql
query { siteRoutes(draft: true) { path methods cache { defaultTtl } flows { handle methods } } }
```

```graphql
query { siteTemplates(draft: true) }
```

```graphql
query { siteTemplate(path: "home.liquid", draft: true) }
```

```graphql
query { siteStaticFile(path: "css/site.css") { path size contentType content publicUrl } }
```

Statics are served publicly under the **`/static/` prefix**: a file saved at
path `css/site.css` is reachable at `<publicUrl>/static/css/site.css` — in
templates link it as `<link rel="stylesheet" href="/static/css/site.css">`.
Note `publicUrl` is the site's public BASE (primary custom domain or
`<sub>.<base>`), not the file URL — append `/static/<path>` yourself.

```graphql
query { siteQueryLibrary(draft: true) { name query } }
```

```graphql
query { sitePublishStatus { lastPublishedAt sitemapUrls } }
```

```graphql
query { siteSubdomain { subdomain baseDomain } }
```

```graphql
query { siteSubdomainAvailable(name: "acme") { available reason } }
```

```graphql
query { siteDomains { domains { id domain status isPrimary verifyToken } featureEnabled edgeIp } }
```

## 3. Site mutations

22 mutations + 10 queries (32 site fields). Write defaults are DRAFT
(`draft: Boolean = true`) for routes,
templates, queries AND static files;
domain/subdomain ops are live immediately.
Generic success ack: `StatusResult { status: String! }` — `"ok"`, or
`"deleted"` when a delete hit an already-missing object (idempotent).

| Field | Args | Returns | Notes |
|---|---|---|---|
| `saveSiteRoutes` | `routes: [SiteRouteInput!]!, draft: Boolean = true` | `StatusResult!` | Replaces the WHOLE routes document. Read-modify-write. |
| `saveSiteRoute` | `originalPath: String, route: SiteRouteInput!, draft: Boolean = true` | `StatusResult!` | Upsert one route. With `originalPath` set it locates the OLD entry (rename case); with `originalPath` omitted it replaces an existing route with the SAME path (same-path save never duplicates) or appends when none exists. |
| `deleteSiteRoute` | `path: String!, draft: Boolean = true` | `StatusResult!` | Idempotent (404 → `"deleted"`). A draft delete records a tombstone — publish removes the route from live; re-saving the path retracts it. |
| `publishSiteRoute` | `path: String!` | `StatusResult!` | Publishes ONE route (or its draft deletion) to live — explicit intent that FORCES the draft version even when a full publish would keep a newer live item. Rejects `SITE_PUBLISH_VALIDATION_FAILED` for corrupt draft JSON and routes with no flow bindings. |
| `saveSiteTemplate` | `path: String!, content: String!, draft: Boolean = true` | `StatusResult!` | Writes Liquid source as a string. |
| `uploadSiteTemplate` | `path: String!, file: Upload!, draft: Boolean = true` | `StatusResult!` | Multipart; file body becomes the template content. |
| `deleteSiteTemplate` | `path: String!, draft: Boolean = true` | `StatusResult!` | Idempotent. |
| `saveSiteStaticText` | `path: String!, content: String!, draft: Boolean = true` | `StatusResult!` | Draft-first: writes `static/_draft/<path>`; the preview host serves it immediately. `draft: false` writes live directly (system maintenance only). |
| `uploadSiteStatic` | `path: String!, file: Upload!, draft: Boolean = true` | `StatusResult!` | Draft-first binary upload (multipart). |
| `deleteSiteStatic` | `path: String!, draft: Boolean = false` | `StatusResult!` | `draft: true` discards only the draft copy ("cancel changes"); the default removes the published file immediately. Idempotent. |
| `publishSiteStatic` | `path: String!` | `StatusResult!` | Promotes ONE static file's draft to live and clears the draft (the per-file counterpart of `publishSiteTemplate`). |
| `saveSiteQuery` | `entry: SiteQueryInput!, draft: Boolean = true` | `StatusResult!` | `SiteQueryInput { name: String!, query: String! }`. The query must be syntactically valid GraphQL — see "Query-library syntax rules" below. |
| `deleteSiteQuery` | `name: String!, draft: Boolean = true` | `StatusResult!` | `SITE_QUERY_IN_USE` when routes still reference it (see below). Idempotent. Draft deletes skip that check (gated at publish) and write a tombstone. |
| `publishSiteQuery` | `name: String!` | `StatusResult!` | Publishes ONE library entry (or its draft deletion) to live, forcing the draft version. A deletion whose query is still referenced by a live route rejects `SITE_QUERY_IN_USE`. |
| `publishSiteTemplate` | `path: String!` | `StatusResult!` | Promotes ONE template's draft (`templates/_draft/<path>`) to live and clears it. A draft failing compile validation rejects `TEMPLATE_COMPILE_FAILED` with the parser detail. |
| `publishSite` | — | `SitePublishResult` | Merges ALL drafts into live per item (recency by server `updatedAt` — see `references/conventions.md`); validates first. |
| `invalidateSiteCache` | — | `StatusResult!` | Flushes the renderer's response caches on demand; rarely needed — the generation-keyed caches already drop on ANY site mutation (route/template/query edit, publish) automatically. |
| `setSiteSubdomain` | `subdomain: String!` | `StatusResult!` | `SITE_SUBDOMAIN_TAKEN`, `SUBDOMAIN_INVALID`, `SUBDOMAIN_RESERVED`. |
| `createSiteDomain` | `domain: String!` | `SiteDomain` | Registers a custom domain (pending verification). |
| `verifySiteDomain` | `domainId: ID!` | `SiteDomain` | Re-checks DNS; returns the fresh row. |
| `setPrimarySiteDomain` | `domainId: ID!` | `StatusResult!` | Primary domain backs `publicUrl`. |
| `deleteSiteDomain` | `domainId: ID!` | `StatusResult!` | |

Route input shape (whole-document and single-route writes):

```graphql
input SiteRouteInput {
  path: String!
  methods: [String]             # omitted/empty → ["GET"]
  sitemapQuery: String          # dynamic routes: library query enumerating the URLs for the sitemap
  cache: SiteRouteCacheInput    # { defaultTtl: Int } — 0 = live
  flows: [SiteRouteFlowInput]   # [{handle, methods, headerName?, headerValue?}] — flow bindings
  membersOnly: Boolean          # gate anonymous visitors (GET redirect + POST denial)
  loginPath: String             # members_only redirect target
  memberCookie: String          # session cookie name the gate resolves
}
```

EVERY route must carry at least one flow binding, and every binding needs
≥1 method from `GET|POST|PUT|DELETE`. The full binding model (dispatch,
header-conditioned bindings, multi-route flows, merge rules) is
[flows-guide.md §6](flows-guide.md). Publish rejects a binding-less route
with `route <path>: no flow bindings`.

`sitemapQuery` (validated against the library at write time) names a query
that enumerates the route's URLs. Substitution is by EXACT field name: every
`:param` in the path is filled from the enumerated items' field of the same
name — a route `/terms/letter/:letter` needs items carrying a `letter` field,
and items missing any param field are skipped (no URL). There is NO
`slug`/`id` fallback. Set it on every dynamic route (catch-alls included):
only `sitemapQuery` enumerates a dynamic route's URLs — without one the
route contributes NOTHING to the sitemap. Queries that
enumerate for the sitemap must declare their variables NULLABLE
(`$slug: String`, not `String!`): the publisher preflight runs them without
path vars, and a `String!` kills the route's whole sitemap contribution.

`publishSite` result and failure shape:

```graphql
type SitePublishResult {
  templatesCopied: Int!
  staticsCopied: Int!
  routesPromoted: Boolean!
  queriesPromoted: Boolean!
  sitemapUrls: Int!
  publishedAt: DateTime
  routesConflicts: [String!]    # live paths KEPT: a live write is newer than the draft copy
  queriesConflicts: [String!]   # same, by query name
}
```

```graphql
mutation { publishSite { templatesCopied staticsCopied routesPromoted queriesPromoted sitemapUrls publishedAt routesConflicts queriesConflicts } }
```

On validation failure the mutation returns an error with message
`SITE_PUBLISH_VALIDATION_FAILED` and `extensions.details: [String]` — one
human-readable line per problem, e.g.
`route /blog: no flow bindings`,
`routes.draft.json is corrupt: …`. Fix the listed routes,
then re-publish.

`deleteSiteQuery` conflict: error message `SITE_QUERY_IN_USE` with
`extensions.routes: [String]` listing the route paths that reference the query
name. A query referenced by FLOWS (a graphql node's `library_name`, tracked
as the "flow_bound" sidecar) also 409s `SITE_QUERY_IN_USE` — in that case
the `routes` extension is empty. Detach the flows or routes referencing it,
then retry.

Rate limiting: render-service management caps are 120 mutations/min and
100 publishes/min per tenant (RENDER_MGMT_PUBLISH_PER_MIN). Exceeding them surfaces `SITE_RATE_LIMITED` with
`extensions.retry_after` (seconds). Public POST/PUT/DELETE bodies are capped
at 10 MB (RENDER_PUBLIC_MAX_BODY_BYTES) — an oversized JSON/XML body fails
the parse with 400, never an unbounded buffer.

Examples:

```graphql
mutation {
  saveSiteRoutes(routes: [
    { path: "/", methods: ["GET"], flows: [{ handle: "home_page", methods: ["GET"] }] },
    { path: "/blog", cache: { defaultTtl: 300 }, flows: [{ handle: "blog_page", methods: ["GET"] }] }
  ], draft: true) { status }
}
```

```graphql
mutation { saveSiteTemplate(path: "home.liquid", content: "<h1>hello</h1>", draft: true) { status } }
```

`saveSiteTemplate` compile-validates the Liquid source before storing: a
template with a syntax error is rejected with `TEMPLATE_COMPILE_FAILED` and
the parser diagnosis (line number, when the engine provides one) in the error
detail; nothing is saved. There is no template size limit.

```graphql
mutation { saveSiteQuery(entry: { name: "posts", query: "query { blogPosts(limit: 10) { id title } }" }, draft: true) { status } }
```

(A fresh tenant starts empty — only the `media` content entity is seeded;
create `page`/`navigation`/`page-seo` via plain `createEntity` when needed.)

```graphql
mutation { setSiteSubdomain(subdomain: "acme") { status } }
```

```graphql
mutation { createSiteDomain(domain: "www.acme.com") { id status verifyToken } }
```

## 4. Entity model (content types & entries)

Two layers: **entity definitions** (the schema — static `entities`/`entity`
queries + `createEntity`/`updateEntity`/`deleteEntity` mutations, admin-only)
and **typed per-entity CRUD** (auto-generated per definition).

### Discovering the schema

```graphql
query {
  entities { slug name description properties { name type_slug required unique is_array localized entity_ref config } }
}
```

`entity(slug: String!, includeInherited: Boolean = true): EntityDefinition`
returns one definition; `includeInherited: false` shows only own properties.
Full `EntityDefinition` fields: `id`, `name`, `slug`, `description`,
`properties: [PropertyDefinition]`, `version: Int`, `implements: [String]`,
`extends: String`, `baseType`, `category`, `isAbstract`, `isBuiltin`,
`permissions`. `PropertyDefinition` fields: `name`, `type` (alias for
`type_slug`), `type_slug`, `required`, `unique`, `is_array`, `localized`,
`entity_ref`, `config` (send an OBJECT or a JSON-encoded string — both
persist; responses return it as a string), `conditions`, `validation`.

### Creating a content type

`createEntity(input: CreateEntityInput!): EntityDefinition` — admin role
(`schema:manage`). `CreateEntityInput`: `name: String!`, `slug: String!`
(kebab-case, canonicalized), `description`, `properties: [PropertyInput!]`,
`implements: [String]`, `extends: String` (parent slug, immutable after
create). `PropertyInput`: `name: String!`, `type: String!` (the type slug —
`string`, `text`, `select`, `number`, `int`/`integer`, `boolean`/`bool`,
`date`, `datetime`, `json`, `array`, `entity`, `bcrypt`; media fields are
`entity` with `entity_ref: "media"`; there is no `markdown` type — markdown
content is `text`, whose default editor is markdown), `required`, `unique`,
`is_array`, `localized`, `entity_ref` (target slug for `entity` type),
`config` (send an OBJECT — e.g. `{options:[{label,value}]}` for select — or
a JSON string; both persist), `conditions`, `validation`.
`bcrypt` values are hashed on write with the tenant pepper and masked to
`***` on every read except the render-service key; a value over 72 bytes is
rejected with `FIELD_PASSWORD_TOO_LONG`. A slug outside the
list above (e.g. `markdown`, `media`, or a bare entity slug like `author`)
is rejected at definition time with `PROPERTY_TYPE_INVALID` — the same
error from `createEntity` and `updateEntity`.

`updateEntity(slug: String!, input: UpdateEntityInput!)` — same shape,
everything optional; `extends` is ignored (immutable). `input.properties` is
**UPSERT BY NAME**: a property whose name already exists REPLACES that
definition in place (same position); a new name APPENDS at the end. It never
deletes — properties you don't send keep their definitions, and an empty
list is a no-op. Deletion is explicit via `input.remove_properties:
[String!]` (property names to drop); an unknown name errors with a localized
`Property '...' does not exist on this entity.`, and a name in BOTH
`properties` and `remove_properties` is a conflict error. Rename in ONE call
(one schema rebuild): upsert the new definition and put the old name in
`remove_properties` — the renamed property moves to the END of the list.
`deleteEntity(slug: String!): Boolean`.

```graphql
mutation {  # rename `title` → `heading`: upsert the new name, remove the old
  updateEntity(slug: "blog-post", input: {
    properties: [{ name: "heading", type: "string", required: true }]
    remove_properties: ["title"]
  }) { slug properties { name type_slug } }
}
```

Schema rebuild after `createEntity`/`updateEntity` is asynchronous — poll
introspection until the new type/field resolves before using it.

```graphql
mutation {
  createEntity(input: {
    name: "Blog post"
    slug: "blog-post"
    properties: [
      { name: "title", type: "string", required: true }
      { name: "slug", type: "string", required: true, unique: true }
      { name: "body", type: "text" }
      { name: "status", type: "select", config: "{\"options\":[{\"label\":\"Draft\",\"value\":\"draft\"},{\"label\":\"Published\",\"value\":\"published\"}]}" }
      { name: "publishedAt", type: "datetime" }
    ]
  }) { slug properties { name type_slug } }
}
```

### Name derivation (slug → GraphQL names)

From a kebab-case slug `blog-post`:

| Thing | Rule | Example |
|---|---|---|
| Object type | PascalCase(slug) | `BlogPost` |
| Create input | `<Type>Input` | `BlogPostInput` |
| Update input | `<Type>UpdateInput` (all fields nullable) | `BlogPostUpdateInput` |
| Where input | `<Type>Where` | `BlogPostWhere` |
| OrderBy input / enum | `<Type>OrderBy` / `<Type>OrderByField` | `BlogPostOrderBy` / `BlogPostOrderByField` |
| Singular query | camelCase(slug) | `blogPost` |
| Plural query | pluralized camelCase | `blogPosts` |
| Count query | plural + `Count` | `blogPostsCount` |
| Mutations | `create<Type>` / `update<Type>` / `delete<Type>` | `createBlogPost` |

Pluralization: consonant+`y` → `-ies` (`category` → `categories`);
vowel+`y` → `+s` (`workshopDay` → `workshopDays`, `key` → `keys`);
sibilant endings (`s`, `x`, `z`, `ch`, `sh`) → `+es` (`masterclass` →
`masterclasses`, `address` → `addresses`); otherwise `+s`. Special cases:
`menu`→`menus`, `setting`→`settings`. When unsure, read the exact field name
from `entities { slug }` + introspection.

**Reserved type names.** A slug whose PascalCase form collides with a name
the dynamic schema reserves for itself is rejected at `createEntity` with
`TYPE_NAME_RESERVED`. The full list — keep a fallback slug ready for these:

`Node`, `Entity`, `Timestamped`, `EntityDefinition`, `PropertyDefinition`,
`Condition`, `ValidationRule`, `APIKey`, `APIKeyMutation`, `Query`,
`Mutation`, `CreateEntityInput`, `UpdateEntityInput`, `PropertyInput`,
`ConditionInput`, `ValidationRuleInput`, `AuthPayload`.

**Select labels in templates.** A select property's human labels live in
`config.options` — serialized as a JSON string templates can't parse. The
structured `options` field on PropertyDefinition exposes them
(`{label, value}` per choice); a
library query can carry them to templates — no per-site lookup entity
needed (dogfood #9 friction):

```graphql
kind_labels: entity(slug: "piece") { properties { name options { label value } } }
```

```liquid
{% for p in kind_labels.data.properties %}{% if p.name == "kind" %}
  {% for o in p.options %}{% if o.value == piece.kind %}{{ o.label }}{% endif %}{% endfor %}
{% endif %}{% endfor %}
```

**Schema rebuild convergence.** Definition mutations rebuild the tenant's
GraphQL schema asynchronously. During a rapid batch a rebuild can race the
writes; such requests get the machine-readable `SCHEMA_REBUILDING` error
instead of a partial schema — retry after a beat. Convergence is guaranteed:
once the batch stops writing, the next request rebuilds from the full
snapshot. After a batch of `createEntity`, verify ALL new types landed via
introspection (the admin UI retries automatically; raw API clients should
too).

### Typed CRUD

```graphql
blogPost(id: ID, slug: String, locale: String): BlogPost
blogPosts(where: BlogPostWhere, orderBy: [BlogPostOrderBy!], limit: Int = 10, offset: Int = 0, search: String, locale: String): [BlogPost]
blogPostsCount(where: BlogPostWhere, search: String): Int
createBlogPost(input: BlogPostInput!): BlogPost
updateBlogPost(id: ID!, input: BlogPostUpdateInput!): BlogPost
deleteBlogPost(id: ID!): Boolean
```

- The `slug` argument on the singular query exists only when the entity has a
  top-level `slug` property marked `unique`. Omit both `id` and `slug` →
  `ENTITY_ID_OR_SLUG_REQUIRED`. A not-found lookup returns `null`, not an
  error.
- **`limit` defaults to 10 and is hard-capped at 100** — a larger value is
  silently clamped. Paginate with `offset` + `blogPostsCount`.
- `update` is a partial merge: absent fields keep their stored values;
  explicitly-passed `null` clears. Required fields absent from the payload are
  fine (only explicitly-nulled required fields are rejected).
- Data mutations are checked against per-entity permissions (default RBAC:
  editor+ may create/update; viewer is read-only) — a `data:write` scoped key
  covers the common case.

### Date and datetime values

`date` and `datetime` property values are STRINGS on the wire. Accepted forms:

- Full RFC3339 timestamp — `"2026-07-28T10:00:00Z"` (the canonical form; use
  it when the field carries a moment in time).
- Bare calendar date — `"2026-07-28"` (accepted for both `date` and
  `datetime`; midnight is assumed).

Anything else (`"28.07.2026"`, `"July 28"`, garbage) is rejected at create/
update time with `FIELD_INVALID_DATE` naming the field — fix the format and
retry. Filters (`DateTimeFilter`) take the same string forms.

### Query-library syntax rules

`saveSiteQuery` parses the query BEFORE saving and rejects a syntactically
invalid one with `SITE_QUERY_INVALID_SYNTAX` plus the parser location. The
two valid shapes:

- Braced shorthand — `{ pages(where: {slug: {eq: "home"}}) { title } }`.
  The opening `{` is mandatory: `pages(where: ...) { title }` WITHOUT braces
  is a syntax error (a bare field name is not a valid start of an operation).
- Named operation — `query($slug: String) { ... }` or `mutation { ... }`.
  Use the named form whenever the query declares variables.

Only SYNTAX is checked at save time; whether a field actually exists on your
entity types surfaces at render preflight (and a failing ref still blanks the
route's whole data context — preflight is all-or-nothing per route), so
introspect once before writing the library.

### Filtering (`where`) and sorting (`orderBy`)

`<Type>Where` contains one field per filterable property, plus `id`,
`created_at`, `updated_at`, and `AND: [<Type>Where!]` / `OR: [<Type>Where!]` /
`NOT: <Type>Where`. Filterable property types: `string`, `text`, `select`,
`number`, `int`/`integer`, `boolean`/`bool`, `datetime`/`date`, `entity`
(treated as an id). NOT filterable: `json` and polymorphic block arrays.
Arrays of filterable scalars use `ArrayMembershipFilter`.

Operator inputs (per property type):

| Property type | Filter input | Operators |
|---|---|---|
| string / text / select | `StringFilter` | `eq`, `neq`, `in: [String!]`, `notIn: [String!]`, `contains`, `startsWith`, `endsWith`, `isNull: Boolean` |
| number / int / integer | `NumberFilter` | `eq`, `neq`, `gt`, `gte`, `lt`, `lte` (Float), `in: [Float!]`, `isNull: Boolean` |
| boolean / bool | `BooleanFilter` | `eq`, `isNull: Boolean` |
| datetime / date | `DateTimeFilter` | `eq`, `neq`, `gt`, `gte`, `lt`, `lte` (String values), `isNull: Boolean` |
| entity (reference) | `IDFilter` | `eq: ID`, `in: [ID!]`, `isNull: Boolean` |
| id (built-in) | `IDFilter` | `eq`, `in` |
| array property | `ArrayMembershipFilter` | `contains`, `eq`, `in`, `notIn` |

**Filtering an entity-reference by slug needs two steps.** The reference is
stored as a bare UUID inside the row, so `{chef: {slug: {eq: …}}}` does not
exist — the filter sees only IDs. Resolve the target first, then filter by
ID: `chef(slug: "anna") { id }` (the singular `slug` argument exists
whenever the target entity has a unique slug property) →
`classes(where: {chef: {eq: $id}})`. A single `search: "anna"` is the
one-call alternative when sub-string matching suffices — search descends
one level into entity-reference targets.

Notes: it is `neq` (not `ne`). Numeric filter operators are Float — declare
numeric filter VARIABLES as `Float`, never `Int`: an `Int` variable in a
Float position passes save-time validation but kills the route's whole
preflight at render time (all-or-nothing, silent — the page renders with an
empty data context). Int stays correct for non-filter arguments like
`$offset`/`$limit`. `contains`/`startsWith`/`endsWith` are
case-INSENSITIVE (SQL `ILIKE`) and treat `%`/`_` in the value literally.
Datetime bounds are strings. A **null operator value skips the operator**
(GraphQL null-as-absent): `eq: $v` with `$v = null` filters nothing — this is
what makes optional URL-query-param filters safe, in flat multi-field `where`
and `AND:`/`OR:` groups alike (see workflows.md §3).
`isNull: true` matches rows where the stored value is absent (missing key or
JSON null); `isNull: false` matches rows with a value. The classic trap: an
unset boolean is NULL, so `{eq: false}` misses it — an "unapproved queue" is
`{OR: [{approved: {isNull: true}}, {approved: {eq: false}}]}`.

`search` free-text: matches the entity's OWN string/text/select property
values AND — one level deep — the content of its entity-reference targets
(searching records by the referenced label's name finds them; polymorphic
block arrays are not searched). Case-insensitive. An empty `search: ""` is a
no-op.

**Property-name collisions across entities.** Every entity type participates
in the polymorphic block union, so a property NAME shared with another entity
must keep a compatible TYPE — the built-in block shapes already claim e.g.
`label` (String, in link/stat blocks). Creating an entity with `label` as an
entity-ref (or any non-string type) fails with
`BLOCK_UNION_FIELD_TYPE_CONFLICT` — rename the property (e.g. `record_label`).
Prefer specific names over generic ones when modeling data. The two numeric
kinds are mutually compatible: `int` and `number` may share a name across
entities (your `position: int` next to the seeded navigation's
`position: number` is fine) — the union expander aliases them per member and
nothing is lost. Non-numeric mismatches (string vs number, scalar vs
entity-ref, …) still require a rename. Known seeded collision: the built-in
`docs-page` entity has a String property `category`, so creating an
entity-ref property named `category` fails with
`BLOCK_UNION_FIELD_TYPE_CONFLICT` — pick another name.

**Entity references: self-refs allowed, cross-type cycles rejected.** A
DIRECT self-type reference is legal — e.g. a `related: entity → term`
property on the `term` type itself. Use self-refs for see-also graphs
instead of CSV text fields. Cross-type reference cycles (A → B → A) are
still rejected with `CIRCULAR_ENTITY_REF`.

`orderBy` is a list of `{ field: <Type>OrderByField!, direction: OrderDirection }`
with `OrderDirection` ∈ `ASC` | `DESC` (absent direction = `ASC`). Enum values
are the SCREAMING_SNAKE field names: `ID`, `CREATED_AT`, `UPDATED_AT`, plus
each sortable property. For a camelCase property the snake boundary is the
camel boundary: `startsAt` → `STARTS_AT`, `durationMin` → `DURATION_MIN`.
Only non-array
scalar properties sort — and the enum contains ONLY that entity's own
fields: ordering by a field that exists on a DIFFERENT entity fails the
whole preflight at render time (one failing ref drops the route's
bindings), so validate `orderBy` fields against the entity before
publishing.

End-to-end example — create an entry, then list published ones newest-first:

```graphql
mutation {
  createBlogPost(input: {
    title: "Hello world"
    slug: "hello-world"
    body: "First post!"
    status: "published"
    publishedAt: "2026-07-18T10:00:00Z"
  }) { id title slug }
}
```

```graphql
query {
  blogPosts(
    where: { status: { eq: "published" }, OR: [{ title: { contains: "hello" } }, { slug: { startsWith: "hello" } }] }
    orderBy: [{ field: PUBLISHED_AT, direction: DESC }]
    limit: 20
  ) { id title slug status }
  blogPostsCount(where: { status: { eq: "published" } })
}
```

## 5. Forms

Registered only in multi-tenant mode (single-tenant CMS has no forms client).
`site:manage` capability gates handles/actions/flows; `data:write` gates
submission reads/deletes.

Flows (formerly "handles") are the flow containers the site's routes bind
to; `slug` is the flow's TENANT-UNIQUE identifier that joins it to
submissions and runs (the pre-P6 `"<route_path>:<slug>"` composite is
gone).

### Queries

```graphql
flows: [Flow]                   # the flow-container list
flow(id: ID!): Flow
flowDoc(slug: String!, draft: Boolean): FlowDocResult
                               # draft: true returns the staged copy + has_draft
flowRuns(submission_id: String, limit: Int = 100): [FlowRun]
flowRunsForFlow(slug: String, limit: Int = 100): [FlowRun]
landingSubmissions(slug: String, limit: Int = 100, offset: Int = 0): [LandingSubmission]
landingSubmissionSummary: [SubmissionSummaryRow]
```

Shapes:

```graphql
type Flow {
  id: ID!
  tenant_id: String!
  slug: String!              # defaults to "default" when omitted on create
  route_path: String!
  name: String
  enabled_cors: Boolean!
  allowed_origins: [String!]
  member_cookie: String
  on_get_mode: String        # "" = off; "params" = explicit trigger-param list; "always"
  on_get_params: [String!]
  flow_live: Boolean!        # true = the flow has a live document at all
  flow_has_draft: Boolean!   # true = a staging copy exists and differs from live
  created_at: DateTime
  updated_at: DateTime
}

type FlowDocResult {
  slug: String!
  flow: String               # flow = JSON document string
  has_draft: Boolean!        # on draft reads: the served copy differs from live
}

type FlowRun {
  id: ID!
  tenant_id: String!
  submission_id: String!
  node_id: String
  action_type: String!
  status: String!
  error_message: String
  response_snippet: String
  duration_ms: Int!
  started_at: DateTime
}

type LandingSubmission {
  id: ID!
  tenant_id: String!
  route_path: String!
  form_name: String!
  payload: String            # JSON string of the submitted fields
  ip: String
  user_agent: String
  created_at: DateTime
}

type SubmissionSummaryRow {
  slug: String!
  submissions_7d: Int!
  total: Int!
  last_activity: DateTime
}

type DryRunResult {
  steps: [DryRunStep!]!     # one per visited node, in walk order
  directives: [String!]!    # JSON-encoded set_cookie/redirect/respond_json/render directives
  suppressed: [String!]!    # side-effecting nodes recorded but NOT executed
  error: String             # walk-level error; null on a clean run
}

type DryRunStep {
  node: String!             # node id
  type: String!             # entry | action | condition
  status: String!
  branch: String            # branch taken (condition nodes)
  mocked: Boolean           # node answered from context (graphqlResults/httpResults) — no HTTP
  unmocked: Boolean         # node had NO mock — a placeholder {} was substituted,
                            # the walk kept going; fill context.graphqlResults[<result_key>]
                            # or context.httpResults[<result_key>]
  suppressed: Boolean       # side effect withheld (send_email/notify_telegram/http_post
                            # without result_key)
  detail: String            # per-step diagnostics incl. `field_not_resolved: …`
}
```

### Mutations

```graphql
dryRunFlow(flow: String!, context: String!): DryRunResult
                               # MUTATION (execution semantics, like saveFlowDoc) —
                               # executes an UNSAVED flow document against a mocked
                               # request; see "Dry run" below
createFlow(slug: String, route_path: String!, name: String, enabled_cors: Boolean = false, allowed_origins: [String!]): Flow
updateFlow(id: ID!, slug: String, name: String, enabled_cors: Boolean, allowed_origins: [String!], member_cookie: String): Flow
deleteFlow(id: ID!): DeleteResult
saveFlowDoc(slug: String!, flow: String!): FlowDocResult
publishFlowDoc(slug: String!): FlowPublishResult
                        # FlowPublishResult { slug: String!, published: Boolean! }
deleteLandingSubmission(id: ID!): DeleteResult
```

`DeleteResult { id: ID!, deleted: Boolean! }`.

Notes:

- `createFlow`: `slug` optional — normalized (whitespace → `-`), empty →
  `"default"`. Duplicates → `FLOW_DUPLICATE`; bad slug →
  `FORM_HANDLE_SLUG_INVALID`; bad origins → `FORM_HANDLE_INVALID_ORIGIN`.
- `updateFlow` preserves fields you don't send. `member_cookie` pins
  which sealed session cookie the handle's member resolution accepts (empty =
  any); route bindings use the ROUTE's `memberCookie` instead.
- `config` and `flow` are JSON strings; malformed JSON → `INVALID_JSON`.
- `saveFlowDoc` writes the flow's STAGING copy only — a flow goes
  live ONLY via `publishFlowDoc(slug)` (`publishSite` does NOT
  promote flow drafts; the preview host executes the staged copy, POST flow
  mutations included).
- Flow validation failures return `FORM_FLOW_INVALID`, or the specific
  `FORM_FLOW_CYCLE` / `FORM_FLOW_SHAPE` / `FORM_FLOW_CONDITION_BAD`. The full
  flow engine contract (nodes, actions, Liquid context, route bindings,
  export format) is `references/flows-guide.md`; member login flows are
  `references/member-auth.md`.
- Not-found ids → `NOT_FOUND`; other failures → `REQUEST_FAILED`.

Example — a contact flow on `/contact` that sends an email:

```graphql
mutation {
  h: createFlow(route_path: "/contact", slug: "contact", name: "Contact form") { id slug }
}
```

```graphql
mutation {
  saveFlowDoc(
    slug: "contact"
    flow: "{\"version\":2,\"nodes\":[{\"id\":\"e\",\"type\":\"entry\"},{\"id\":\"m\",\"type\":\"action\",\"data\":{\"action_type\":\"send_email\",\"config\":{\"to\":\"sales@acme.com\",\"subject\":\"New contact request\"}}}],\"edges\":[{\"from\":\"e\",\"to\":\"m\"}]}"
  ) { slug }
}
```

```graphql
query { landingSubmissions(slug: "contact", limit: 50) { id payload created_at } }
```

### Dry run

`dryRunFlow(flow, context)` executes a flow document that does NOT have to
be saved — both arguments are JSON strings (`flow` = the same document
string `saveFlowDoc` takes; `context` = the mocked request, shape
in [flows-guide.md "Dry run"](flows-guide.md)). Graphql nodes answer from
`context.graphqlResults` and `result_key` http_post nodes from
`context.httpResults` (no HTTP; the captcha guard branches on
`httpResults.captcha.success`). A result_key with NO mock gets a
placeholder {} substituted and its step is marked `unmocked: true` — the
walk keeps profiling the WHOLE flow (downstream conditions on that key fail
closed with visible `field_not_resolved` markers); `flowctl dryrun` prints a
ready-to-fill results skeleton at the end; `send_email`/`notify_telegram`/
`http_post` without `result_key` are suppressed (recorded, never executed);
`set_cookie`/`redirect`/`respond_json`/`render` collect directives in
`directives` (a `respond_json` body is truncated to 512 chars); `render`
renders against the real template store. Requires `site:manage`; no side
effect ever fires.

### flowctl CLI

The DEFAULT tool — reach for it before hand-rolled curl whenever a
subcommand exists (flows, site files, runs, dry-run). Local companion for
flow work. Fetch the single binary for your platform, one-time download:

```
curl -fsSL https://dynapi.ru/cms/agent-skill/cli/flowctl-linux-amd64 -o flowctl && chmod +x flowctl
```

(Windows: `.../flowctl-windows-amd64.exe`; macOS: `.../flowctl-darwin-arm64`
Apple Silicon, `.../flowctl-darwin-amd64` Intel.)

The endpoint is built in — no URL flag or variable needed. Instead of
repeating `--key` on every call, export it once per session — every
networked subcommand picks it up (an explicit flag still wins):

```
export DYNAPI_APIKEY="dca_…"
```

- `./flowctl validate <flow.json>` — parses the document, runs the save-time
  validator, Liquid-syntax-checks every string config leaf, and lists the
  `secret://` references the flow depends on (action configs AND condition
  clause values). Fully offline.
- `./flowctl dryrun <flow.json> --context <ctx.json> --url <graphql> --key <dca_…>`
  — calls `dryRunFlow` and pretty-prints steps/directives/suppressed.
- `./flowctl files push <file-or-dir>... --url <graphql> --key <dca_…>
  [--base <dir>] [--as template|static] [--publish]` — uploads site
  templates/statics from the local filesystem through the multipart
  mutations. File bytes travel as multipart parts — Liquid content never
  gets JSON-escaped. A directory uploads recursively with relative paths
  (subdirectories become site-file folders); `.liquid` → template,
  everything else → static (`--as` overrides); draft by default,
  `--publish` promotes each file right after its upload.
- `./flowctl save <flow.json> --url <graphql> --key <dca_…>
  [--slug <s>] [--route-path </p>]` — drafts the flow document
  (`saveFlowDoc`; the flow is a STRING). Creates the flow container only
  when the tenant has none by that slug. Accepts a `.dynflow.json` export
  or a bare `{version,nodes,edges}` document (then `--slug/--route-path`
  give the handle identity). **save NEVER publishes** — publishing is a
  separate explicit step.
- `./flowctl publish <slug> --url <graphql> --key <dca_…>` — promotes
  one flow draft live (`publishFlowDoc`).
- `./flowctl runs <slug> --url <graphql> --key <dca_…> [--limit <n>]` — the
  per-node run map as TEXT: per node — action type, node-execution count,
  error count, average duration, last activity, and the last error per
  failing node. CONDITION nodes carry a branches column (then N · else M) —
  every condition evaluation is journaled with its taken branch, so the
  full funnel is visible (e.g. c_mem else = guests redirected to /login).
  Members-only routes add a synthetic `guard` row (type `guard`, status
  `skipped`, branches `guard N`): anonymous visitors the route's member gate
  bounced BEFORE the flow ran — the funnel's very top. The snippet says
  where the guest went: `guest→403` = a JSON client (the request's `Accept`
  started with `application/json` — true for GET and POST alike; the body's
  Content-Type plays no role) answered with 403 AUTHENTICATION_REQUIRED;
  `guest→<loginPath>` = everyone else, redirected (302 GET / 303 POST).
  Guard rows are
  sampled under load (first ~20/min per route verbatim, then 1:20), so a
  storm inflates the real count, not the journal; raise `--limit` when
  guard rows crowd out node runs in the recent window.
  The screen-free health picture of a live flow (the admin Runs tab renders
  the same data for humans). Note: the header counts recorded node
  executions, not flow launches.
- `./flowctl apply <flow.dynflow.json> --url <graphql> --key <dca_…>
  [--publish]` — the full import recipe in one call: handle + flow draft +
  templates (draft) + routes (draft, export snake_case keys normalized to
  the GraphQL input's camelCase); `--publish` also promotes the flow.
  Query-library entries in the file are reported, not applied.
- `./flowctl route <list|save|publish|delete>` — the route half of the site
  map. `list` prints every route with its flow bindings and member gate;
  `save <route.json>` drafts one SiteRouteInput object (or an array) —
  `--publish` promotes right after, `--original` renames;
  `publish <path>` promotes one path (and takes a drafted deletion live);
  `delete <path>` drafts a tombstone (`--publish` makes it live).
- `./flowctl query <list|save|publish>` — the site query library.
  `save <entry.json|query.graphql>` drafts an entry (bare .graphql needs
  `--name`); `--publish` promotes; `list` shows names.
- `./flowctl media push <file>…` — upload to the tenant media library via
  `uploadMediaBatch` (multipart; bytes never JSON-escaped). Prints one line
  per file: `id  url  name` — entity media-reference fields store the id,
  templates embed the url. `media refs <id>` lists where a media file is
  used (entity.property → item id) — the pre-delete safety check.
- `./flowctl entity <types|schema|items|get|create|update|delete>` —
  content entities (pages, catalog items, any dynamic type) without
  hand-rolled GraphQL. The dynamic field names (plural query,
  create/update/delete mutations, input types) are computed from the slug
  by the SAME canonical rules the schema registers (shared
  internal/entitynames package) — they cannot drift. `types` lists every
  entity type; `schema <slug>` its properties; `items <slug>` lists rows
  (`--fields a,b` previews data, `--limit/--offset/--search` pages);
  `get <slug> <id>` prints one item as JSON; `create <slug> <data.json>`
  prints the new id; `update <slug> <id> <data.json>` is a partial merge;
  `delete <slug> <id>`.
- `./flowctl subdomain [name] [--set]` — no argument: current subdomain +
  live/preview URLs; a name: availability check (exit 1 when taken/
  reserved, so scripts loop candidates); `--set`: check then claim via
  `setSiteSubdomain`.
- `./flowctl secret <set|list|delete>` — the write-only secret store behind
  `secret://` references. `set <name>` reads the value from stdin (pipe it
  in — the safe path) or `--value`; `list` prints names only (values are
  never readable); `delete` is idempotent — flows still referencing the
  name fail closed.

## 6. Media

`uploadMedia(file: Upload!): UploadResult!` uploads the file to storage AND
creates a `media` entity in one call. `uploadMediaBatch(files: [Upload!]!):
[UploadResult!]!` does the same for many files. Both need the `media:write`
capability and count against the tenant's storage quota.

```graphql
type UploadResult {
  id: ID        # media entity id
  name: String  # original filename
  url: String   # public URL — use this in content and templates
  mimeType: String
  fileSize: Int
  type: String  # "image" | "video" | "audio" | "document"
}
```

Multipart curl (see section 1 for the transport):

```bash
curl -X POST "https://dynapi.ru/cms/graphql" \
  -H "X-API-Key: dca_..." \
  -F 'operations={"query":"mutation ($file: Upload!) { uploadMedia(file: $file) { id name url mimeType fileSize type } }","variables":{"file":null}}' \
  -F 'map={"0":["variables.file"]}' \
  -F '0=@./hero.jpg'
```

Referencing media in content:

- In entity data: `media`-typed and `entity`-typed properties referencing
  `media` store the media **entity id** (the `id` from `UploadResult`).
- In Liquid templates and site queries: resolve the media entity and use its
  `url` property.
- Uploaded files are also queryable as entities (`media` content type, plural
  `medias`) like any other entity data.
- Before deleting a media entity, check usage:
  `mediaUsage(mediaId: ID!): [{ entityType: String!, count: Int! }]!` and
  `mediaReferences(mediaId: ID!): [{ id: ID!, entityType: String!, property: String!, required: Boolean! }]!`.

## 7. Tenant secrets

The tenant's encrypted secret store behind `secret://<name>` references in
flow configs and condition clause values (mechanics in
[flows-guide.md "Secrets"](flows-guide.md)).
All fields need the `site:manage` capability.

| Field | Args | Returns | Notes |
|---|---|---|---|
| `tenantSecrets` | — | `[TenantSecret!]!` | Names + timestamps only. |
| `upsertTenantSecret` | `name: String!, value: String!` | `TenantSecret` | Create or overwrite by `(tenant, name)`. |
| `deleteTenantSecret` | `name: String!` | `Boolean!` | Idempotent. |

```graphql
type TenantSecret {
  name: String!        # ^[a-zA-Z0-9_-]{1,128}$
  createdAt: DateTime
  updatedAt: DateTime
}
```

The store is WRITE-ONLY: no field ever returns a stored value — after
`upsertTenantSecret` the value cannot be viewed again; re-enter it to
change. Error codes: `SECRET_NAME_INVALID` (name outside
`^[a-zA-Z0-9_-]{1,128}$`), `SECRET_LIMIT_REACHED` (more than 100 secrets
per tenant), `SECRET_VALUE_TOO_LARGE` (a value over 8 KB),
`SECRET_NOT_FOUND` (a flow references an unknown name —
fail-closed at execution; logs show the `secret://` reference, never a
value).
