# Page Types
> Source: /cms/page-types
> Partner and loyalty program join pages, the draft/published/archived lifecycle, clone-on-edit, member gating, routing, and environment promotion

# Page types

A CMS page carries exactly one `page_type`. The type is fixed in code, not
registry data, because it changes which modules are available and how the page
is reached.

| `page_type`            | Purpose                                          |
| ---------------------- | ------------------------------------------------ |
| `partner`              | General partner and campaign pages — the default |
| `loyalty_program_join` | The join page for a specific loyalty program     |

A `loyalty_program_join` page is bound to its program through
`core.cms_loyalty_program_join_pages`, keyed by `program_id` (the partner's custom loyalty program `external_id`) with a unique
`page_group_id`. One cusotm loyalty program has at most one join page, and deleting the program
cascades.

## Module availability by type [#module-availability-by-type]

The page type gates part of the palette:

| Block type           | `partner`   | `loyalty_program_join` |
| -------------------- | ----------- | ---------------------- |
| `program_join_form`  | unavailable | available              |
| `signup`             | available   | unavailable            |
| `member_auth_action` | available   | unavailable            |
| everything else      | available   | available              |

A join page owns its own join form, so the generic signup and member-auth
modules would be redundant and ambiguous there.

## Lifecycle [#lifecycle]

`state` is one of `draft`, `published`, or `archived`.

Pages are grouped by a ULID `page_group_id` rather than a single row. A page
group holds at most one row per state, enforced by
`idx_cms_pages_group_state_unique`.

### Clone-on-edit [#clone-on-edit]

Editing a published page does not mutate it. A draft row is cloned from the
published row and edited in isolation; publishing promotes the draft
transactionally. This is why slug uniqueness is `(slug, state)` rather than a
flat unique on `slug` — a draft intentionally shares its slug with the
still-live published sibling it will replace.

Cloning preserves layout `nodeKey` values and remaps internal IDs, so layout and
block identity survive the round trip and in-flight layout changes cannot leak
into the live page.

## Member gating [#member-gating]

Two columns on `core.cms_pages` control access:

| Column                   | Meaning                                           |
| ------------------------ | ------------------------------------------------- |
| `member_auth_required`   | the page is member-gated                          |
| `member_gate_program_id` | the program a turned-away visitor is sent to join |

`member_gate_program_id` overrides owner attribution; `NULL` falls back to it.
It is an internal id — resolution emits the program's `external_id`.

The gate program is resolved from the **route's** owner rather than from the
page's ownership record, so it does not depend on the caller having requested
the `entityOwner` hydrate. A client that omitted that hydrate would otherwise
silently receive `null` and bounce its visitors to a bare sign-in screen.

Member-gated pages are always `noindex` and `nofollow` — see
[SEO details](/cms/seo).

## Routing [#routing]

`core.cms_page_routes` maps a page group to a path, an owner, and optionally a
custom domain.

| Constraint                                                 | Effect                               |
| ---------------------------------------------------------- | ------------------------------------ |
| unique `page_group_id`                                     | one route per page group             |
| unique `(owner_entity_type, owner_entity_id, path)`        | no duplicate path within an owner    |
| unique `(custom_domain_id, path)` where domain is not null | no duplicate path on a custom domain |

Every owner also gets one **fallback host** in `core.cms_fallback_hosts`,
created automatically the first time a route is made for that owner and reused
thereafter. Its label is derived from the owner's external id, so the hostname
is always `{owner external id}.sites.journey.com` — a working URL before any
custom domain exists. The suffix is `CMS_SITE_HOST_SUFFIX`; the label is
normalized to a valid 63-character DNS label rather than chosen by an author.

Resolution produces both hostnames and picks a canonical one:

```
canonicalHostname = customHostname ?? fallbackHostname
```

A custom domain only contributes once its status is `active`; a page group whose
custom domain is pending or removed continues to serve on the fallback host.
Deleting a custom domain sets the route's `custom_domain_id` to `NULL` rather
than removing the route.

## Environment promotion [#environment-promotion]

Pages and block schemas are promoted between environments rather than authored
separately in each. The promotion logic lives in
`api/src/core/cms/pages/promotion/`, driven by two scripts:

| Script                                     | Promotes                                              |
| ------------------------------------------ | ----------------------------------------------------- |
| `api/scripts/promote-cms-pages.ts`         | page groups with their layout, blocks, theme, and SEO |
| `api/scripts/promote-cms-block-schemas.ts` | block-type schemas                                    |

Block schemas promote first — a page cannot land in an environment whose
registry lacks its module types.

## Storage [#storage]

| Table                                 | Holds                                            |
| ------------------------------------- | ------------------------------------------------ |
| `core.cms_pages`                      | slug, internal name, type, state, member gate    |
| `core.cms_page_ownership`             | organization, brand, or property owner           |
| `core.cms_page_routes`                | path, owner, optional custom domain              |
| `core.cms_fallback_hosts`             | one host label per owner                         |
| `core.cms_loyalty_program_join_pages` | program to page-group binding                    |
| `core.cms_page_blocks`                | module instances and published compile snapshots |

Source: `api/src/core/cms/pages/schema/`, `api/src/core/cms/pages/promotion/`.
