# Site development workflows

Copy-pasteable recipes for building and publishing a tenant site through the
`site*` GraphQL fields on `POST /cms/graphql`. Every field, argument, and error code
below is verified against `internal/graphql/site_schema.go`,
`internal/graphql/forms_schema.go`, and `internal/render/`.

> **How to RUN these recipes:** the GraphQL blocks document the API shape —
> do not paste them into curl. Execute through flowctl: `save`/`publish` for
> flows and routes, `apply` for a whole page at once (flow + routes +
> queries + templates in one file), `route` for the route map, `query save`
> for the library, `files push` for templates/statics, `entity` for content,
> `media push` for files. Subcommand map: api-reference.md §flowctl.

Auth for every call: `Authorization: Bearer <tenant JWT>` (control-plane login) or
`X-API-Key: <key>` (needs the `site:manage` capability for `site*` and
form/flow fields, plus the `render_service` plan feature).

Core conventions (full detail in `conventions.md`):

- Reads default to the **live** namespace (`draft: false`); writes default to the
  **draft** namespace (`draft: true`). Preview serves drafts; publish promotes them.
- `saveSiteRoutes` replaces the whole routes document; `saveSiteRoute` upserts one
  route. Always read-modify-write.
- Entity-definition changes (`createEntity`, …) rebuild the GraphQL schema
  **asynchronously** — poll introspection before using the new typed mutations.
- **The site is made of FLOWS.** A route is a path + flow
  bindings; a template-less route with a GET binding and a `render` node is a
  page. Inventory first: `flows` + `siteRoutes { path flows { handle
  methods } }` — the flow list IS the site map (flows-guide.md).

---

## 1. site-from-scratch

Bring a fresh tenant from "nothing" to a published site.

### Step 1 — Set the subdomain

```graphql
mutation {
  setSiteSubdomain(subdomain: "acme") {
    status
  }
}
```

On conflict the control plane's `SUBDOMAIN_TAKEN` is surfaced as
`SITE_SUBDOMAIN_TAKEN`. Other coded rejections pass through verbatim:
`SUBDOMAIN_INVALID`, `SUBDOMAIN_RESERVED`. Check availability first with:

```graphql
query {
  siteSubdomainAvailable(name: "acme") {
    available
    reason
  }
}
```

A taken/reserved name is NOT an error here — it comes back as
`available: false` with a `reason` string.

### Step 2 — Create the query library entries (draft)

A new tenant starts with an EMPTY library, routes, and template set — you
author everything. Save each query as a draft entry (see workflow 2, step 2
for the full shape):

```graphql
mutation {
  saveSiteQuery(entry: { name: "posts_list", query: "{ posts(limit: 10, locale: \"ru\") { id title slug } }" }, draft: true) {
    status
  }
}
```

### Step 3 — Create the routes (draft)

One `saveSiteRoute` per path (see workflow 2, step 3). Dynamic routes must set
`sitemapQuery` — the library query that enumerates their URLs for the sitemap.

### Step 4 — Inventory templates

```graphql
query {
  siteTemplates
}
```

Live namespace only; add `siteTemplates(draft: true)` for `templates/_draft/`.
A fresh tenant returns `[]` — write templates yourself
(step 6 / workflow 2).

### Step 5 — Read one template

```graphql
query {
  siteTemplate(path: "home.liquid")
}
```

Returns the raw Liquid body as a string; a missing path errors with
`SITE_TEMPLATE_NOT_FOUND`. Add `draft: true` to read `templates/_draft/home.liquid`.

### Step 6 — Make a draft edit to a template

```graphql
mutation {
  saveSiteTemplate(
    path: "home.liquid"
    content: "<!DOCTYPE html>\n<html><body><h1>{{ posts_list.data.posts[0].title }}</h1></body></html>"
  ) {
    status
  }
}
```

`draft` defaults to `true` — this writes ONLY `templates/_draft/home.liquid` and
does not touch the live site. The draft namespace is per-file; there is no
bulk-copy of main→draft.

For anything bigger than a one-line edit, upload the file FROM DISK instead
of assembling a JSON string — `uploadSiteTemplate` takes the file as a
multipart part (curl example in api-reference.md §1), and `flowctl files
push <dir> --url … --key …` walks a whole local directory (`.liquid` →
template, everything else → static, relative paths preserved). Either way
the Liquid content never gets JSON-escaped.

### Step 7 — Dry-run checkpoint (flows)

Every flow assembled above gets a dry run BEFORE it is saved and published
(flows-guide.md "Dry run"): `dryRunFlow(flow, context)` executes the UNSAVED
document against a mocked request — emails/webhooks are suppressed (recorded,
never sent), graphql nodes answer from `context.graphqlResults` and
`result_key` http_post nodes from `context.httpResults`, and the step trace
shows condition branches including `field_not_resolved` diagnostics.
Run 3–4 scenarios per flow: happy path, empty body, member vs anonymous,
invalid signature. Fix, then save (`saveFlowDoc`).

Fetch the `flowctl` CLI binary, 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.) Run
`./flowctl validate <flow.json>` FIRST as the local pre-check — it parses the
document, runs the save-time validator and Liquid-syntax-checks every config
string offline, and lists the `secret://` references the flow depends on.

The same binary carries the staging steps, so the whole pipeline is one
tool: `validate` → `dryrun` → `save` (draft; NEVER publishes) → `publish`
— and `apply <file.dynflow.json> --publish` replays a full export (flow +
templates + routes) in one call. No wrapper script needed.

### Step 8 — Publish

What goes live when:

- ROUTE-LEVEL settings (path, methods, cache, sitemapQuery, flow bindings,
  member gate) go live on SAVE in the ADMIN UI — the UI runs
  `saveSiteRoute(draft: true)` immediately followed by
  `publishSiteRoute(path)`. When driving the raw API, run the same pair.
- QUERY-LIBRARY and FILE writes (templates, statics) are DRAFT-FIRST: the
  preview host executes drafts; promote per item with `publishSiteQuery` /
  `publishSiteTemplate` / `publishSiteStatic`, or in bulk with `publishSite`.
- FLOW BODIES stage and need `publishFlowDoc(slug)` EACH —
  `publishSite` never promotes flow drafts (below).

Bulk promotion of query-library and file drafts:

```graphql
mutation {
  publishSite {
    templatesCopied
    routesPromoted
    queriesPromoted
    sitemapUrls
    publishedAt
  }
}
```

Publish validates first (corrupt draft JSON and routes with no flow bindings
are rejected). Validation failure returns
`SITE_PUBLISH_VALIDATION_FAILED` with a `details: [String!]` **extension** listing
each violation (the message stays the bare code). On success: draft templates are
copied to main, routes and queries are merged **item-by-item** into live by
`updatedAt` recency (a live write newer than the draft copy survives and is
reported in `routesConflicts`/`queriesConflicts` — see `conventions.md`), the
sitemap is regenerated and `robots.txt` gets a `Sitemap:` line. Check state
any time with `sitePublishStatus { lastPublishedAt sitemapUrls }`.

Per-flow promotion (the flow BODY is the only artifact `publishSite` skips):

```graphql
mutation {
  publishFlowDoc(slug: "home") { slug published }
}
```

`published: false` means the staging copy matched live already. The flow's
state is visible on `flows { slug flow_live flow_has_draft }`.

### Step 9 — Verify the public URL

The public URL shape is `https://<subdomain>.<baseDomain>`:

```graphql
query {
  siteSubdomain {
    subdomain
    baseDomain
  }
}
```

With `subdomain: "acme"`, `baseDomain: "dynapi.ru"` → `https://acme.dynapi.ru`.
`GET` it and expect a 200 HTML page.

**Preview URL — the real mechanism.** There is no draft-namespace query param and
no separate preview app: the preview is a **host label prefix** on the tenant subdomain (single-label `preview-<sub>`).
The render service's resolver (`internal/render/tenant_resolver.go` (extractSubdomain),
default prefix from `RENDER_PREVIEW_HOST_PREFIX`, which defaults to `preview-`)
parses `preview-<sub>.<baseDomain>` (single label) and flags the request as
preview. So the preview URL for the example above is
**`https://preview-acme.dynapi.ru`**. A preview request reads:

- routes from `routes.draft.json`, **falling back** to published `routes.json`
  when no draft file exists (nothing pending);
- queries from `queries.draft.json`, **falling back** to `queries.json`;
- templates from `templates/_draft/` **with a per-file fallback** to the
  published body: a file the reviewer edited renders its
  draft, one without a staged copy renders live — and the same draft-first
  overlay covers static assets (`static/_draft/` → `static/`).

Practical consequence: to preview a coherent site you must have at least one
draft routes document (any `saveSiteRoute(draft: true)` seeds the draft from the
live routes as a complete working copy) and a draft copy of every template the
flows render. The preview host exists only on the platform subdomain —
custom domains never serve drafts. `POST`/`PUT`/`DELETE` form submissions and
flow mutations run on the preview host against draft routes and staged flow
copies — forms, member login and registration are testable on preview before
publishing; submissions persist for real and rate limits apply.

---

## 2. add-a-page

Add one static page to a published site using the seeded `page` entity.

### Step 1 — Create the page entry

The `page` entity is seeded per tenant (`internal/bootstrap/content_entities.go`,
slug `"page"`) with properties: `title` (string, required, localized), `slug`
(string, required, unique), `status` (select: draft/published), `seo` (entity →
`page-seo`), `blocks` (entity array). Entries are created with the **typed
mutation** the schema generator derives from the slug — `page` → PascalCase
`Page` → `createPage(input: PageInput!)` (same convention the admin SPA uses,
`admin/src/services/graphql-service.ts:172-185`):

```graphql
mutation {
  createPage(input: {
    title: "About us"
    slug: "about"
    status: "published"
  }) {
    id
  }
}
```

`blocks` is optional — a page with no blocks is fine for simple content. Note
the seeded `page` has no plain `body` field; free-form content goes into
`blocks` (entity refs to block types like `hero-section`) or you use a custom
entity. Editing later: `updatePage(id: ..., input: PageUpdateInput!)` — the
update input is all-nullable (partial update, merge semantics).

### Step 2 — Save the query (draft)

```graphql
mutation {
  saveSiteQuery(
    entry: {
      name: "about_page"
      query: "{ pages(where:{slug:{eq:\"about\"}}){ id title slug } }"
    }
  ) {
    status
  }
}
```

`name` is the library key a flow's graphql node references (`library_name`).
The query is a full GraphQL document (string). Static queries need no
variables; parameterized ones use `$var` GraphQL variables the node binds in
its `variables` map — Liquid-rendered against the flow context (see
workflow 3).

**Checkpoint — dry-run every saved query with EMPTY variables before preview.**
`saveSiteQuery` checks syntax only; a query that declares `$price_min: Int`
against a Float filter position (or hits any other runtime validation error)
surfaces NOT at save time but as an all-or-nothing preflight failure at
render: the page comes back blank with no error visible. One extra call per
query catches it in seconds — run the query text as-is against
`/cms/graphql` with `"variables": {}` and fix anything that returns
`errors[]`:

```json
{"query": "<the exact query text you just saved>", "variables": {}}
```

### Step 3 — Save the route (draft)

Routes bind FLOWS, not templates or query refs. The page flow's graphql node
(`result_key: "about_page"`) fetches the data and feeds the template;
`results.<key>` is ALSO lifted into the render context as `<key>`, so
`about_page.data.<field>` in the template still works.

```graphql
mutation {
  saveSiteRoute(
    route: {
      path: "/about"
      methods: ["GET"]
      cache: { defaultTtl: 300 }
      flows: [{ handle: "about_page_flow", methods: ["GET"] }]
    }
  ) {
    status
  }
}
```

`originalPath` (optional) is the route's current path when **renaming/moving** —
omit it to append. On the first draft write the draft document is seeded from
the live `routes.json` (complete-working-copy), so existing routes are carried
over. `methods` defaults to `["GET"]` when omitted. Every route needs ≥1 flow
binding.

A route saved with path exactly `/404` is special: it becomes the site-wide
custom not-found page, served automatically with HTTP 404 for every unmatched
path (the preview host uses the draft `/404` route; its template must not
depend on `:params` — there are none). Details in `liquid-guide.md`
("404 and error pages").

### Step 4 — Save the template (draft)

```graphql
mutation {
  saveSiteTemplate(
    path: "about.liquid"
    content: "<article><h1>{{ about_page.data.pages[0].title }}</h1></article>"
  ) {
    status
  }
}
```

Each graphql node's result is exposed in Liquid under its `result_key` as the
full GraphQL response envelope (`about_page`) — access data via
`about_page.data.<field>` (here `about_page.data.pages[0].title`, since the
query selects `pages`).

### Step 5 — Preview check

Open `https://preview-<sub>.<baseDomain>/about`. The draft route + draft
template + (draft-or-live) query library must all be in place — see the preview
rules in workflow 1, step 9. If the page renders the generic fallback, the
draft template is missing; if it 404s, the draft routes document is missing.

### Step 6 — Publish

Promote the template draft per file, or in bulk:

```graphql
mutation { publishSiteTemplate(path: "about.liquid") { status } }
```

```graphql
mutation {
  publishSite {
    templatesCopied
    routesPromoted
    queriesPromoted
    sitemapUrls
  }
}
```

The route's flow binding went live at save time (workflow 1, step 8). Then
verify `https://<sub>.<baseDomain>/about` returns 200.

---

## 3. blog-with-dynamic-routes

A blog section with an index page and per-post URLs (`/blog` + `/blog/:slug`).

### Step 1 — Create the BlogPost entity definition

```graphql
mutation {
  createEntity(input: {
    name: "Blog Post"
    slug: "blog-post"
    description: "Blog articles rendered by the public site"
    properties: [
      { name: "title", type: "string", required: true }
      { name: "slug", type: "string", required: true, unique: true }
      { name: "content", type: "text" }
      { name: "published", type: "bool" }
    ]
  }) {
    id
    slug
  }
}
```

`PropertyInput` fields (`internal/graphql/type_builder.go:985-1005`): `name`!,
`type`!, `required`, `unique`, `is_array`, `localized`, `entity_ref`,
`config` (object or JSON string), `conditions`, `validation`. Common `type`
values: `string`, `text`, `select`, `number`, `int`, `bool`, `datetime`,
`entity`. There is no `markdown` type — markdown content is `text` (the
`text` editor is markdown by default).
Requires the admin/owner role (`ADMIN_ROLE_REQUIRED` otherwise).

Slug-to-name mapping for everything this entity generates (kebab → PascalCase →
naive English plural): `blog-post` → type `BlogPost` → input `BlogPostInput` /
`BlogPostUpdateInput`, singular field `blogPost`, plural field `blogPosts`,
mutations `createBlogPost` / `updateBlogPost` / `deleteBlogPost`.

**The schema rebuild is asynchronous.** The definition row is committed before
the mutation returns, but `createBlogPost`/`blogPosts` appear only after the
rebuild lands (goroutine in single-tenant mode; per-tenant cache invalidation in
MT mode — `internal/graphql/rebuilder.go`). Poll introspection until the field
shows up (typically seconds):

```graphql
query {
  __type(name: "Mutation") {
    fields {
      name
    }
  }
}
```

`config` (used by `select` options) is a loose JSON scalar — send either an
inline object (`config: {options: [{label: "Draft", value: "draft"}]}`) or a
JSON-encoded string; both persist. Select options set through
`createEntity` work — no need to avoid config-carrying property types when
defining entities via GraphQL.

### Step 2 — Create sample entries

After the rebuild lands (introspection shows `createBlogPost`):

```graphql
mutation {
  first: createBlogPost(input: {
    title: "Hello world"
    slug: "hello-world"
    content: "# Hello\n\nFirst post."
    published: true
  }) {
    id
  }
  second: createBlogPost(input: {
    title: "Second post"
    slug: "second-post"
    content: "# Second\n\nMore text."
    published: true
  }) {
    id
  }
}
```

### Step 3 — Query library entries with limit/offset and the `$slug` variable

How params flow into queries: a flow's graphql node carries a `variables` map
whose values are Liquid-rendered against the flow context before the
preflight call:

- `"slug": "{{ params.slug }}"` — a **path param** captured from the route
  pattern (`/blog/:slug` + `/blog/hello` → `slug = "hello"`);
- `"tag": "{{ query.tag }}"` — a **URL query param** (`?tag=foo`). An ABSENT
  or EMPTY string leaf is OMITTED from the variables, so the variable
  resolves to `null`, and a null filter operator (`eq: $tag` etc.) is
  skipped — so `where: {genre: {eq: $tag}}` is a safe OPTIONAL filter: the
  default page shows everything, `?tag=rock` filters. No `contains`
  workaround needed. (`search: ""` is likewise a no-op.)
- `"limit": "10"` — any other value passes through as a **literal
  constant**. Variables are typed-coerced per their DECLARATION in the
  query: a literal `"10"` bound to `$limit: Int` (or `"true"` to
  `$flag: Boolean`) is converted to a proper JSON number/bool before the
  preflight call — GraphQL itself never coerces a string into Int.
- Required-Boolean pattern: `"v": "{{ query.v }}"` with
  `query($v: Boolean!)` — an absent param executes with `false`.

Rendered values (truncated to 256 runes) are sent as GraphQL **`variables`**
on the preflight request — never string-interpolated into the query. The
library query declares the variable and uses it: `$slug` with
`where: {slug: {eq: $slug}}`.

```graphql
mutation {
  saveSiteQuery(
    entry: {
      name: "blog_index"
      query: "{ blogPosts(limit: 10, offset: 0, where: {published: {eq: true}}, orderBy: [{field: CREATED_AT, direction: DESC}]) { id title slug } }"
    }
  ) {
    status
  }
}
```

```graphql
mutation {
  saveSiteQuery(
    entry: {
      name: "blog_post_by_slug"
      query: "query($slug: String) { blogPosts(where: {slug: {eq: $slug}}, limit: 1) { id title slug content } }"
    }
  ) {
    status
  }
}
```

Notes: plural fields accept `where`, `orderBy: [{field, direction}]`, `limit`
(default 10), `offset`, `search`. **`orderBy.field` is the ENUM value, not the
property name** — `CREATED_AT`, `TITLE`, never `"created_at"`: a quoted string
saves into the query library without complaint but fails the query at
preflight/render time (and one failing ref drops ALL of the route's bindings —
see `liquid-guide.md`). `<Type>Where` nests one operator input per
filterable property (`slug: {eq: $slug}`), plus `AND`/`OR`/`NOT`. A unique
top-level `slug` property also enables a singular field `blogPost(slug: "...")`
if you prefer. Filtering uses eq/neq/gt/gte/lt/lte/contains/startsWith/endsWith
per property type.

### Step 4 — Flows + routes

The listing page is a GET flow: a graphql node (`library_name: "blog_index"`)
fetches the posts, a render node draws the listing template. The detail page
is the same shape, with the graphql node's variables binding the path param:
`"variables": {"slug": "{{ params.slug }}"}` (step 3). The library queries
from step 3 stay valid as-is. Bind each flow to its route:

```graphql
mutation {
  blogIndex: saveSiteRoute(
    route: {
      path: "/blog"
      methods: ["GET"]
      cache: { defaultTtl: 300 }
      flows: [{ handle: "blog_index_flow", methods: ["GET"] }]
    }
  ) {
    status
  }
  blogPost: saveSiteRoute(
    route: {
      path: "/blog/:slug"
      methods: ["GET"]
      cache: { defaultTtl: 300 }
      flows: [{ handle: "blog_post_flow", methods: ["GET"] }]
    }
  ) {
    status
  }
}
```

Both writes are draft (default). Path patterns use Gin-style `:param` segments;
matching prefers STATIC over `:param` at each position regardless of pattern
length — a static segment always beats a param segment, so a catch-all
`/:section` can never shadow `/about`.

### Step 5 — Templates

```graphql
mutation {
  indexTpl: saveSiteTemplate(
    path: "blog/index.liquid"
    content: "<ul>{% for post in blog_index.data.blogPosts %}<li><a href=\"/blog/{{ post.slug }}\">{{ post.title }}</a></li>{% endfor %}</ul>"
  ) {
    status
  }
  postTpl: saveSiteTemplate(
    path: "blog/post.liquid"
    content: "<article><h1>{{ blog_post_by_slug.data.blogPosts[0].title }}</h1>{{ blog_post_by_slug.data.blogPosts[0].content }}</article>"
  ) {
    status
  }
}
```

Template addressing is unchanged: the flow's `results.<key>` envelope is
lifted as `<key>.data` in the render context, so `blog_index.data.blogPosts`
and `blog_post_by_slug.data.blogPosts[0]` resolve exactly as before.

### Step 6 — Preview, then publish

Preview `https://preview-<sub>.<baseDomain>/blog` and `/blog/hello-world`
(remember: preview needs the draft routes document and draft copies of both
templates — the step-4 `saveSiteRoute` calls created the draft routes; the
step-5 template saves created the draft templates). Then:

```graphql
mutation {
  publishSite {
    templatesCopied
    routesPromoted
    queriesPromoted
    sitemapUrls
  }
}
```

Dynamic routes are also expanded in the sitemap at publish time — via the
route's `sitemapQuery`, the ONLY enumeration source (a route without one
contributes nothing). Substitution is by EXACT field name: every `:param` in the
path is filled from the enumerated items' field of the same name —
`/blog/:slug` needs items carrying a `slug` field, and items missing any
param field are skipped (no URL). There is NO `slug`/`id` fallback; a
dynamic route (catch-alls included) with no enumerating query contributes
NOTHING. 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.

Duplicate URLs collapse: one item per value yields one `<loc>` per value —
enumerating `/prices/:category` over items that carry a repeating
`category` field produces the 4 distinct URLs, not one per item. When you
need one URL per DISTINCT VALUE (not per item), make the enumerating query
return one item per value — a small dictionary entity whose property equals
the value, or a query over that dictionary (24 items × 4 categories → 4
URLs, not 24).

For this blog, save an INDEX query (no param filter) and point the route's
`sitemapQuery` at it — one `<loc>` per post:

```graphql
mutation {
  saveSiteQuery(entry: { name: "blog_sitemap", query: "{ blogPosts(limit: 100) { slug } }" }) { status }
}
```

Then add `sitemapQuery: "blog_sitemap"` to the `/blog/:slug` route (step 4)
and re-publish.

If a dynamic route contributes ZERO URLs (compare `sitemapUrls` against your
expectations), either it has no `sitemapQuery`, its items
lack the param field, or an enumerating query failed the publish-time
preflight — most often a non-nullable variable (`String!`) or a query that
doesn't parse / references a non-existent field. The failure is logged
server-side and the route is silently skipped from the sitemap; fix the
query and `publishSite` again.

### Client-side dynamic loading ("показать ещё")

Browser JS gets JSON from a FLOW endpoint: bind a flow to a GET path (e.g.
`/blog/more`), add a graphql node reading the offset from `query.*`, and
finish with a `respond_json` render node (`Content-Type: application/json`).
The page's
JS then fetches that path — conditions and member context are available in
the flow, and the no-JS baseline stays intact (server renders page 1
inline; JS only appends):

```html
<button id="more" data-offset="10">Показать ещё</button>
<script>
document.getElementById('more').onclick = async (e) => {
  const offset = e.target.dataset.offset;
  const res = await fetch(`/blog/more?offset=${offset}`);
  if (!res.ok) return;                       // 429/502 → {"error": "…"}
  const envelope = await res.json();         // render from envelope.data
  appendPosts(envelope.data.blogPosts);
};
</script>
```

---

## 4. contact-form

A contact form on a published page: handle + automation flow + template markup +
submission test + reading submissions.

### Step 1 — Create the contact flow on a route

```graphql
mutation {
  createFlow(
    slug: "contact"
    route_path: "/contact"
    name: "Contact form"
    enabled_cors: false
  ) {
    id
    slug
    route_path
    slug
  }
}
```

`slug` is optional (omitted → `"default"`; whitespace normalized to `-`).
`route_path` must equal the **published page path** the form POSTs to. The
POST reaches the flow through the ROUTE'S BINDING (a `methods: ["POST"]`
binding, optionally header-conditioned).
`slug` (the composite key used everywhere else) is
`route_path + ":" + slug` — here `"contact"`. Errors:
`FORM_HANDLE_SLUG_INVALID`, `FLOW_DUPLICATE` (409-equivalent),
`FORM_HANDLE_INVALID_ORIGIN` (only when `allowed_origins` is set badly).
For cross-origin fetch submissions set `enabled_cors: true` +
`allowed_origins: ["https://example.com"]`.

### Step 2 — Attach the automation flow

The flow document is a v2 JSON graph (full reference in `forms-flow.md`).
Contact form: entry → send_email → terminal.

```graphql
mutation {
  saveFlowDoc(
    slug: "contact"
    flow: "{\"version\":2,\"nodes\":[{\"id\":\"n1\",\"type\":\"entry\"},{\"id\":\"n2\",\"type\":\"action\",\"data\":{\"action_type\":\"send_email\",\"config\":{\"to\":[\"sales@example.com\"],\"subject\":\"New contact request\",\"body_template\":\"Name: {{form.name}}\\nEmail: {{form.email}}\\nMessage: {{form.message}}\",\"smtp\":{\"host\":\"smtp.example.com\",\"port\":587,\"username\":\"noreply@example.com\",\"password\":\"…\",\"from\":\"noreply@example.com\",\"from_name\":\"Acme\"}},\"on_error\":\"continue\"}},{\"id\":\"n3\",\"type\":\"terminal\"}],\"edges\":[{\"from\":\"n1\",\"to\":\"n2\"},{\"from\":\"n2\",\"to\":\"n3\"}]}"
  ) {
    slug
  }
}
```

`flow` is a **JSON string**. Validation codes: `FORM_FLOW_INVALID` (generic),
`FORM_FLOW_CYCLE`, `FORM_FLOW_SHAPE`, `FORM_FLOW_CONDITION_BAD`, plus
`INVALID_JSON` for unparseable input and `NOT_FOUND` when the handle doesn't
exist. Every `send_email` node carries its own `smtp` object —
`{host, port, username?, password?, from, from_name?}`: ask the user for
their mail server and write the creds into the node. Read it back
with `flowDoc(slug: "contact") { flow }`.

### Step 3 — Template form markup

The public form contract (verified in `internal/render/formpost.go`):

- The form **POSTs to the same path as the page** (`action="/contact"` or no
  action attribute). There is no dedicated form endpoint.
- Content type: browser `application/x-www-form-urlencoded` (PRG flow) or
  `application/json` (fetch with the header set explicitly). The reliable JS
  recipe is `body: new URLSearchParams(data)` — it sets
  `application/x-www-form-urlencoded` automatically; a plain object or string
  body makes `fetch` send `text/plain`, which the form parser rejects with 404.
- Routing: the POST reaches the handle whose flow is bound to this path's
  route with `methods: ["POST"]` (flows-guide.md §6) — create that binding
  before publishing.
- Honeypot: include hidden fields named `_company`, `_website`, or
  `_phone_extra`; if any is non-empty the submission is silently accepted (200)
  and dropped.
- If Cloudflare Turnstile is enabled platform-wide (`RENDER_TURNSTILE_SECRET`),
  the rendered form must include the widget and submit
  `cf-turnstile-response`; otherwise the POST fails with `CAPTCHA_FAILED`.

```graphql
mutation {
  saveSiteTemplate(
    path: "contact.liquid"
    content: "<h1>Contact</h1><form method=\"post\" action=\"/contact\"><input type=\"text\" name=\"name\" placeholder=\"Name\" required><input type=\"email\" name=\"email\" placeholder=\"Email\" required><textarea name=\"message\" placeholder=\"Message\" required></textarea><input type=\"text\" name=\"_company\" style=\"display:none\" tabindex=\"-1\" autocomplete=\"off\"><button type=\"submit\">Send</button></form>"
  ) {
    status
  }
}
```

Showing a "thanks" state after the PRG redirect: the template's Liquid context
contains only the flow's result envelopes (`results.<key>` lifted as
`<key>.data`; URL params are not template bindings), so a bare
`{% if submitted %}` is not available. The simplest reliable recipe is a few
lines of inline JS reading `?submitted=1`:

```liquid
{% raw %}<div id="thanks" hidden>Thanks — your request has been sent.</div>
<script>if (new URLSearchParams(location.search).get('submitted') === '1')
  document.getElementById('thanks').hidden = false;</script>{% endraw %}
```

The no-JS recipe is the flow condition pattern — see step 6.

Create the `/contact` route + flow as in workflow 2, then publish.

### Step 4 — Test the submission against the public endpoint

POST to the page path on the **public** host (preview-host POSTs run the
staged flow — testable there before publish):

```bash
curl -i -X POST https://<sub>.<baseDomain>/contact \
  -H "Content-Type: application/json" \
  -H "Accept: application/json" \
  -d '{"name":"Jane","email":"jane@example.com","message":"Hi"}'
```

Responses (verified in `formpost.go`):

- `Accept: application/json` → `201 Created` `{"ok":true,"id":"<submission-id>"}`
  (or 200 `{"ok":true,"id":""}` for a honeypot hit).
- Browser form POST → `303 See Other` redirect to the same path with
  `?submitted=1` appended (PRG — refresh doesn't re-POST).
- Errors: 404 when the path has no POST-bound flow; 429
  `RATE_LIMIT_EXCEEDED` (per-tenant or per-IP bucket, `Retry-After: 60` —
  per-IP default 10/min); 403
  `CAPTCHA_FAILED` when Turnstile is on and the token is missing/invalid; 400
  `INVALID_REQUEST_BODY` for an unparseable body.

The automation flow runs asynchronously after the 201 — the HTTP response never
reflects flow success. Check the run trail via `flowRunsForFlow`.

### Step 5 — Read the submission

```graphql
query {
  landingSubmissions(slug: "contact", limit: 20, offset: 0) {
    id
    form_name
    payload
    ip
    user_agent
    created_at
  }
}
```

`payload` is the stored JSON (honeypots and the Turnstile control field
stripped before
persist). Args: `slug` (optional — omit for all forms), `limit` (default
100), `offset` (default 0). Requires `data:write` (note: this query is gated on
`data:write`, NOT `site:manage`). Aggregate view:
`landingSubmissionSummary { slug submissions_7d total last_activity }`.
Per-node execution audit of the flow:
`flowRunsForFlow(slug: "contact") { id action_type status error_message duration_ms started_at }`
— entry/condition/terminal nodes are not recorded, only action visits.

### Step 6 — PRG thanks-state without JavaScript

The FLOW way, no JS anywhere:

- The POST flow ends with a **redirect** node: `location: "?submitted=1"`,
  `status: 303`.
- The page is a GET flow with a **condition** node whose predicate is
  `{"field": "query.submitted", "operator": "eq", "value": "1"}` — the
  then-branch renders the thanks section/template, the else-branch renders
  the normal page (the form).

Route carrying both bindings:

```json
{ "path": "/contact", "methods": ["GET", "POST"],
  "cache": {"defaultTtl": 3600},
  "flows": [{"handle": "contact_page", "methods": ["GET"]},
            {"handle": "contact_submit", "methods": ["POST"]}] }
```

This is the no-JS thanks pattern (PRG): the browser POST gets a
`303 See Other` to `?submitted=1`, the follow-up GET takes the condition's
then-branch and renders the thanks block server-side. Verify with curl (no
`Accept: application/json`): the POST must answer `303` +
`Location: /contact?submitted=1`, and GET with the param must render the
thanks branch.

### Step 7 — Contextual pre-fill (CTA → pre-filled form)

Every CTA that leads to a form should CARRY CONTEXT, and the form should
pre-fill from it — «Записаться» on a specific class links to
`/workshops?class=tarty#booking` and the form pre-selects that class; «Заказать
похожее» on a detail page links to the form with the piece pre-filled. A
generic form the visitor has to re-contextualize themselves is a defect
(user directive: rounds shipped bare forms + naked CTAs).

The whole pattern is server-side `site.query` — no JS:

- CTA: `<a href="/workshops?class={{ c.slug }}#booking">Записаться</a>`
  (repeat CTAs elsewhere on the site inherit the SAME param shape).
- The page flow's graphql node binds the param:
  `"variables": {"class": "{{ query.class }}"}` — `/workshops?class=tarty`
  and `/workshops` are distinct requests, and the optional filter only
  applies when the param is present (an absent/empty leaf is omitted, so the
  filter is skipped).
- Select pre-fill:
  `<option value="{{ c.id }}" {% if site.query.class == c.slug %}selected{% endif %}>`
- Text pre-fill (context only — item, tariff, date; never personal data):
  `<input name="topic" value="{{ site.query.topic }}">`

Verify: click the CTA on a source page → the target form shows the
pre-selected/pre-filled value server-side (curl the URL with the param and
grep `selected`).

---

## 5. custom-domain

Move the public site from `<sub>.<base>` to the tenant's own domain.

### Step 1 — Check feature availability and edge IP

```graphql
query {
  siteDomains {
    domains {
      id
      domain
      status
      isPrimary
      verifyToken
    }
    featureEnabled
    edgeIp
  }
}
```

`featureEnabled` is the `custom_domains` plan feature (business plan and up —
there is no add-on axis for it). If `false`, `createSiteDomain` fails with
`CUSTOM_DOMAINS_UNAVAILABLE`. `verifyToken` is only returned while a row is
`pending` (spent on activation). `edgeIp` is the IPv4 custom-domain A records
must point at (`PUBLIC_EDGE_IPV4` when configured, else the A record of the
platform apex).

### Step 2 — Add the domain

```graphql
mutation {
  createSiteDomain(domain: "www.example.com") {
    id
    domain
    status
    verifyToken
  }
}
```

Creates a `pending` row and returns its 64-hex-char `verifyToken`. Errors:
`DOMAIN_INVALID`, `DOMAIN_RESERVED`, `DOMAIN_TAKEN`, `DOMAIN_LIMIT_REACHED`
(all 409-class).

### Step 3 — DNS instructions (the real checks)

Verification (`internal/control/domain_service.go:315-375`) runs exactly three
DNS checks, in order:

1. **TXT ownership** — a TXT record at `_dynapi-verify.<domain>` whose value is
   the row's `verifyToken`. Missing TXT → `DOMAIN_VERIFY_TXT_MISSING`; TXT
   exists but no value matches the token → `DOMAIN_VERIFY_TXT_MISMATCH`.
2. **Edge pointing** — the host `<domain>` must resolve (A/AAAA lookup) to the
   platform `edgeIp` from step 1. Not pointed → `DOMAIN_VERIFY_POINTING_MISMATCH`.
3. **CAA policy** — if the domain publishes CAA records, they must not exclude
   Let's Encrypt → `DOMAIN_VERIFY_CAA_BLOCKED`.

So the DNS zone needs:

```
<domain>.            A     <edgeIp>
_dynapi-verify.<domain>.  TXT   "<verifyToken>"
```

### Step 4 — Verify

```graphql
mutation {
  verifySiteDomain(domainId: "<id from step 2>") {
    id
    domain
    status
    isPrimary
    verifyToken
  }
}
```

After the records propagate, this transitions the row `pending → active`
(returns the refreshed row). Any failed check surfaces the matching
`DOMAIN_VERIFY_*` code above. DNS propagation is external — retry after minutes,
not immediately. Activation also approves the domain for on-demand TLS issuance
(Caddy asks the control plane; up to a 30s cache window).

### Step 5 — Make it primary

```graphql
mutation {
  setPrimarySiteDomain(domainId: "<id>") {
    status
  }
}
```

Only `active` domains can become primary (`DOMAIN_INVALID` otherwise). The
primary domain becomes the canonical hostname: the render service 301-redirects
the old `<sub>.<base>` host (and non-primary custom hosts) to it, and
`siteStaticFile.publicUrl` is served from it.

### Step 6 — Publish (or rely on the automatic re-publish)

`setPrimarySiteDomain` **does trigger a re-publish automatically** — the
control-plane handler calls the render service's publish right after the DB
swap (`internal/control/domain_handlers.go:397-419`, best-effort: a failure is
logged and never fails the mutation). The reason is that `sitemap.xml` and
`robots.txt` bake the public base URL into S3 at publish time. Because the
automatic re-publish is best-effort, calling `publishSite` once after switching
primary is a cheap belt-and-braces follow-up:

```graphql
mutation {
  publishSite {
    sitemapUrls
  }
}
```

Verify: `https://<domain>/` returns 200 and `https://<domain>/sitemap.xml`
lists URLs on the new host.

---

## 6. seo-and-structured-data

Turn on meta tags, Open Graph, and JSON-LD microdata. The renderer emits
NOTHING automatically — description, OG, and
structured data are tags YOU render from data in templates. The sitemap is
automatic: `sitemap.xml` is regenerated from the routes' static paths (no
`:param`) at every `publishSite`, baked with the public base URL, and served
from `/sitemap.xml`. `robots.txt` is yours — upload it like any static file;
publish only creates a default when missing and keeps exactly one `Sitemap:`
line current (your custom rules are preserved). `favicon.ico` is a regular
static file you upload.

### Step 1 — Fill `page-seo` entries

The seeded `page-seo` type carries per-page metadata: `meta_title`,
`meta_description`, `keywords` (string array), `canonical_url` (all localized),
`og_image` (media ref), `noindex` (bool). `page` (and `docs-page`) reference it
through their `seo` property:

```graphql
mutation {
  first: createPageSeo(input: {
    meta_title: {ru: "Вёрстка лендингов — Студия", en: "Landing pages — Studio"}
    meta_description: {ru: "Делаем лендинги под ключ.", en: "We build landing pages."}
    keywords: {ru: ["лендинг", "вёрстка"], en: ["landing page"]}
    canonical_url: "https://studio.example.com/services"
  }) { id }
}
```

Localized fields take the whole `{locale: value}` map in ONE mutation (see
conventions.md — separate per-locale updates wipe each other). Link the entry
to its page: `updatePage(id: "…", input: {seo: "<pageSeo id>"})` — or pass
`seo` at page-create time.

### Step 2 — Extend the route's query

Add `seo` to the route's library query, with the locale you are rendering so
localized fields resolve to plain strings server-side. Entity refs resolve
sub-fields, so `og_image { url }` walks into the media entry:

```graphql
{
  pages(where: {slug: {eq: "services"}}, locale: "ru") {
    title
    seo {
      meta_title
      meta_description
      keywords
      canonical_url
      noindex
      og_image { url }
    }
  }
}
```

### Step 3 — Render head tags in `base.liquid`

The base template is included by the page and shares its scope, so it reads
the page's own query bindings. All `<head>` tags belong in `base.liquid`;
the page captures its body into `content` and includes the base. With the
query ref named `page_ctx`, `base.liquid`:

```liquid
{% assign p = page_ctx.data.pages[0] %}
<!DOCTYPE html>
<html>
<head>
  <meta charset="utf-8">
  <title>{{ p.seo.meta_title | default: p.title }}</title>
  {% if p.seo.meta_description %}
    <meta name="description" content="{{ p.seo.meta_description | escape }}">
  {% endif %}
  {% if p.seo.keywords %}
    <meta name="keywords" content="{{ p.seo.keywords | join: ', ' }}">
  {% endif %}
  {% if p.seo.noindex %}
    <meta name="robots" content="noindex, nofollow">
  {% endif %}
  {% if p.seo.canonical_url %}
    <link rel="canonical" href="{{ p.seo.canonical_url }}">
  {% endif %}
  <meta property="og:type" content="website">
  <meta property="og:title" content="{{ p.seo.meta_title | default: p.title | escape }}">
  {% if p.seo.og_image %}
    <meta property="og:image" content="{{ p.seo.og_image.url }}">
  {% endif %}
</head>
<body>
{{ content }}
</body>
</html>
```

and the page template wraps its body:

```liquid
{% capture content %}
  ... page body ...
{% endcapture %}
{% include "base" %}
```

Draft + publish as in workflow 2. One bad filter call or type mismatch fails
the whole render into the fallback page — guard optional fields with
`| default: ''` before filtering.

### Step 4 — JSON-LD microdata

Add a `<script type="application/ld+json">` block in the page template (inside
the captured `content`). Hand-write the structure and interpolate values: `| json` serializes
a Liquid value into valid JSON (quotes handled), `| escape` guards HTML
contexts:

```liquid
<script type="application/ld+json">
{
  "@context": "https://schema.org",
  "@type": "Organization",
  "name": {{ page_ctx.data.pages[0].title | json }},
  "description": {{ page_ctx.data.pages[0].seo.meta_description | json }}
}
</script>
```

For the blog (workflow 3) an `Article` block with `headline`/`datePublished`
interpolated from the post query is the same pattern. Verify by curling the
page and inspecting the `<head>`, or run the URL through Google's Rich Results
Test.

---

## 7. member-login-site (site with its own accounts)

Build visitor accounts with flows only — the full contract is
`references/member-auth.md`; this is the recipe.

### Step 1 — Create the member entity

In the Flows tab open «Пресеты» → any auth preset → the modal offers a
one-click member entity (email + bcrypt password + verified boolean). Via
API:

```graphql
mutation {
  createEntity(input: { name: "Member", slug: "member", properties: [
    { name: "email", type: "string", required: true, unique: true }
    { name: "password", type: "bcrypt" }
    { name: "verified", type: "boolean" }
  ] }) { entity { id slug } }
}
```

### Step 2 — Generate the flows from presets

«Пресеты» → Регистрация (creates `/verify` companion), Вход, Выход, Забыли
пароль (creates `/reset`), Смена пароля. Do not hand-write the graphs until
the preset output works — the generated queries already use the live-probed
list contract (`results.<key>.<plural>[0].<prop>`; there is no `nodes{}`
wrapper in dynamic-entity lists).

### Step 3 — Wire the routes

The presets create template-less GET/POST-bound routes. A members-only
cabinet:

```graphql
mutation {
  saveSiteRoute(route: {
    path: "/cabinet"
    cache: { defaultTtl: 0 }
    flows: [{ handle: "cabinet_page", methods: ["GET"] }]
    membersOnly: true
    loginPath: "/login"
    memberCookie: "member"
  }, draft: true) { status }
}
```

### Step 4 — Publish and verify the full chain

Register → register the same email again (must say "taken", not silence) →
login wrong password (failure path) → login right (session cookie, redirect)
→ cabinet visible → anonymous cabinet visit (302 to /login) → anonymous POST
denied → logout (cookie deleted) → change password (old stops working) →
reset by email link. Check the submissions store: passwords show
`[redacted]`, never plaintext.

## 8. export-import-flows

Move a flow (with its routes, referenced queries, and templates) between
tenants.

1. Export in the Flows tab (or read `flowFlow` + `siteRoutes` and
   assemble the file) — the format is `dynapi-flow` (single) /
   `dynapi-flows` (bundle); see flows-guide.md §8 for the full shape.
2. Import on the target tenant: queries import when the name is free,
   templates when missing (always draft), occupied paths are skipped with a
   toast; on a free path the exported methods merge into one binding.
3. `publishSite` on the target, then verify the routes live.
