Phase 1 of the qbit-cms spec (QRun-IO/qqq#1083, tracking #1094), first vertical slice: the qbit skeleton with feature flags, and the CORE feature end to end. CORE covers sites, domains, redirects, pages, page sections, snippets and menus.
CmsQBitProducer / CmsQBitConfig. Builds everything from the config, with no package scan. It validates the config (RDBMS backend, prefixes, feature dependencies, parent app). A namespace sets the prefixes for a second install. It refuses names that are already on the instance.
CmsFeature flags with dependencies (BLOG requires MEDIA, PUBLISHING requires BLOG or PROJECTS, and so on). Only CORE is implemented; the other features are rejected until they ship, so a host never believes it installed something that is not there.
CORE tables as standard QQQ tables. The names are cms_ plus lowercase snake_case, and the QQQ name equals the SQL name. Each table has snake_case columns, a possible-value source and permission rules, plus field audit when the host has the QQQ audit tables. Child tables (domains, page sections, child pages, menu items) are child lists on their parents and are not in the nav. page.sections and menu.items are associations, so nested writes work.
Admin. One CMS app with a home, and one sub-app per area (Sites, Pages, Navigation), each with its own home. The top home shows each area's totals. An area home shows what needs attention (unpublished or inactive rows), the create actions the user may run, and recent changes. It does not repeat the table counts that Next's app page already lists. Every read runs as the user (PermissionEnforcement.ENFORCE, because widgets render inside a trusted scope), so a table the user may not read is left out instead of shown as zero.
Security.CmsRole bundles (ADMIN, EDITOR, PUBLISHER, DRAFTER, VIEWER), CmsSecurity.applySessionPermissions(session, groups, config) for the host's authentication customizer, and openReads (kof22's current behaviour).
Schema (D5). A prefix-parameterized Liquibase changelog that the host includes and runs (qbit-cms/db/changelog/qbit-cms.changelog.yaml, logical path qbit-cms/changelog):
cms.tablePrefix, cms.features and cms.seedReferenceData properties;
an always-run guard that halts if a table with a CMS name exists but was not created by this changelog;
generic reference data only.
Reference DDL is in docs/ddl/postgres.sql.
Install doctor. A startup schema check with startupModeFAIL_FAST (default), DEGRADED (shown on the CMS home) or SKIP.
kof22 schema match
I ran the changelog on PostgreSQL 17 next to a database built from Website-Backend's own migrations (through 0024, the cms_ rename) and diffed information_schema.columns for the 11 CORE tables. Every column matches on name, type, length and nullability, with two expected exceptions:
kof22's createdate/modifydate are create_date/modify_date here (spec build alignment: kof22 renames them in its cutover changeset);
the six featured-media columns on cms_page belong to the MEDIA feature (next slice).
Constraint names are readable (fk_cms_page_site_id) where kof22 has Hibernate hash names; kof22's cutover renames them before changelog-sync (spec section 3.6.1).
Fresh install into a blank QQQ instance: FAIL_FAST check passes, seeds, CRUD with associations, 5,000-char TEXT, custom prefix, re-run is a no-op, a foreign cms_page halts and is left untouched, seeds off, FAIL_FAST and DEGRADED without the changelog or without CORE
Same install on PostgreSQL: column types, identity sequence, foreign keys, nested insert
CmsQBitProducerTest
Names, backends, PVS, associations, nav (main and reference tables only, no processes), validation messages, namespace second install, collisions, parent app, open reads
CmsSecurityTest
Role bundles; with core enforcement on: viewer cannot write, editor cannot edit reference data, drafter cannot delete, no permissions cannot read; open reads
CmsHomeRendererTest
Empty and real-data homes, escaping, no access, DEGRADED banner, create links only with insert permission
CmsChildListTest
Every child list renders and serializes (this caught a runtime/config reference cycle, fixed here)
These come from a blank QQQ app (CmsDemoServer, test scope) on PostgreSQL with kof22's real CORE content (site, domains, 6 pages, 13 sections, 6 snippets, 2 menus, 23 items), in Next UI 1.0.0-RC.14. There is no horizontal overflow at either width.
MEDIA and BLOG, the rest of phase 1. MEDIA brings CmsStorage with ObjectStorageCmsStorage from qbit-object-storage (D17). Then PROJECTS, PHOTOS, PUBLISHING and STREAM (phase 2).
ESB table events and Quick Search. Both are optional integrations and arrive with phase 2, per the spec's section 2.8.
Public visibility locks. These arrive with the Site API artifact. That artifact generalizes Website-Backend's Site API v2 generator (#123) instead of porting v1 (spec build alignment).
Open-reads metadata. QQQ's CustomPermissionChecker does not receive the permission sub-type, so with openReads the table metadata reports writes as allowed. They are still refused when run. kof22's #107 checker has the same limit. A small QQQ core change would fix this.
Owner steps
Follow the project in CircleCI and add a GitHub user key. Until then the required test_only check cannot run, so this PR cannot merge.
## What
Phase 1 of the qbit-cms spec ([QRun-IO/qqq#1083](https://github.com/QRun-IO/qqq/pull/1083), tracking [#1094](https://github.com/QRun-IO/qqq/issues/1094)), first vertical slice: the qbit skeleton with feature flags, and the **CORE** feature end to end. CORE covers sites, domains, redirects, pages, page sections, snippets and menus.
- **`CmsQBitProducer` / `CmsQBitConfig`.** Builds everything from the config, with no package scan. It validates the config (RDBMS backend, prefixes, feature dependencies, parent app). A namespace sets the prefixes for a second install. It refuses names that are already on the instance.
- **`CmsFeature` flags** with dependencies (BLOG requires MEDIA, PUBLISHING requires BLOG or PROJECTS, and so on). Only CORE is implemented; the other features are rejected until they ship, so a host never believes it installed something that is not there.
- **CORE tables as standard QQQ tables.** The names are `cms_` plus lowercase snake_case, and the QQQ name equals the SQL name. Each table has snake_case columns, a possible-value source and permission rules, plus field audit when the host has the QQQ audit tables. Child tables (domains, page sections, child pages, menu items) are child lists on their parents and are not in the nav. `page.sections` and `menu.items` are associations, so nested writes work.
- **Admin.** One CMS app with a home, and one sub-app per area (Sites, Pages, Navigation), each with its own home. The top home shows each area's totals. An area home shows what needs attention (unpublished or inactive rows), the create actions the user may run, and recent changes. It does not repeat the table counts that Next's app page already lists. Every read runs as the user (`PermissionEnforcement.ENFORCE`, because widgets render inside a trusted scope), so a table the user may not read is left out instead of shown as zero.
- **Security.** `CmsRole` bundles (ADMIN, EDITOR, PUBLISHER, DRAFTER, VIEWER), `CmsSecurity.applySessionPermissions(session, groups, config)` for the host's authentication customizer, and `openReads` (kof22's current behaviour).
- **Schema (D5).** A prefix-parameterized Liquibase changelog that the host includes and runs (`qbit-cms/db/changelog/qbit-cms.changelog.yaml`, logical path `qbit-cms/changelog`):
- `cms.tablePrefix`, `cms.features` and `cms.seedReferenceData` properties;
- an always-run guard that halts if a table with a CMS name exists but was not created by this changelog;
- generic reference data only.
Reference DDL is in `docs/ddl/postgres.sql`.
- **Install doctor.** A startup schema check with `startupMode` `FAIL_FAST` (default), `DEGRADED` (shown on the CMS home) or `SKIP`.
## kof22 schema match
I ran the changelog on PostgreSQL 17 next to a database built from Website-Backend's own migrations (through 0024, the `cms_` rename) and diffed `information_schema.columns` for the 11 CORE tables. Every column matches on name, type, length and nullability, with two expected exceptions:
- kof22's `createdate`/`modifydate` are `create_date`/`modify_date` here (spec build alignment: kof22 renames them in its cutover changeset);
- the six featured-media columns on `cms_page` belong to the MEDIA feature (next slice).
Constraint names are readable (`fk_cms_page_site_id`) where kof22 has Hibernate hash names; kof22's cutover renames them before `changelog-sync` (spec section 3.6.1).
## Tests
`./mvnw verify`: 23 tests, 0 failures; Checkstyle (now wired into `validate`) 0 violations; JaCoCo 97.8% instructions, 100% classes.
| Test | Covers |
|---|---|
| `CmsInstallTest` (H2) | Fresh install into a blank QQQ instance: FAIL_FAST check passes, seeds, CRUD with associations, 5,000-char TEXT, custom prefix, re-run is a no-op, a foreign `cms_page` halts and is left untouched, seeds off, FAIL_FAST and DEGRADED without the changelog or without CORE |
| `CmsPostgresInstallTest` (Testcontainers, PostgreSQL 17) | Same install on PostgreSQL: column types, identity sequence, foreign keys, nested insert |
| `CmsQBitProducerTest` | Names, backends, PVS, associations, nav (main and reference tables only, no processes), validation messages, namespace second install, collisions, parent app, open reads |
| `CmsSecurityTest` | Role bundles; with core enforcement on: viewer cannot write, editor cannot edit reference data, drafter cannot delete, no permissions cannot read; open reads |
| `CmsHomeRendererTest` | Empty and real-data homes, escaping, no access, DEGRADED banner, create links only with insert permission |
| `CmsChildListTest` | Every child list renders and serializes (this caught a runtime/config reference cycle, fixed here) |
| `CmsEntitiesTest`, `CmsFeatureTest` | Entity round trips, section fields exist, feature dependencies |
## Screenshots
These come from a blank QQQ app (`CmsDemoServer`, test scope) on PostgreSQL with kof22's real CORE content (site, domains, 6 pages, 13 sections, 6 snippets, 2 menus, 23 items), in Next UI 1.0.0-RC.14. There is no horizontal overflow at either width.
| | 390 light | 390 dark | 1440 light | 1440 dark |
|---|---|---|---|---|
| CMS home | [png](https://github.com/QRun-IO/qbit-cms/blob/feature/phase-1-skeleton/docs/screenshots/core/home-light-390.png) | [png](https://github.com/QRun-IO/qbit-cms/blob/feature/phase-1-skeleton/docs/screenshots/core/home-dark-390.png) | [png](https://github.com/QRun-IO/qbit-cms/blob/feature/phase-1-skeleton/docs/screenshots/core/home-light-1440.png) | [png](https://github.com/QRun-IO/qbit-cms/blob/feature/phase-1-skeleton/docs/screenshots/core/home-dark-1440.png) |
| Pages home | [png](https://github.com/QRun-IO/qbit-cms/blob/feature/phase-1-skeleton/docs/screenshots/core/pages-home-light-390.png) | [png](https://github.com/QRun-IO/qbit-cms/blob/feature/phase-1-skeleton/docs/screenshots/core/pages-home-dark-390.png) | [png](https://github.com/QRun-IO/qbit-cms/blob/feature/phase-1-skeleton/docs/screenshots/core/pages-home-light-1440.png) | [png](https://github.com/QRun-IO/qbit-cms/blob/feature/phase-1-skeleton/docs/screenshots/core/pages-home-dark-1440.png) |
| Sites home | [png](https://github.com/QRun-IO/qbit-cms/blob/feature/phase-1-skeleton/docs/screenshots/core/sites-home-light-390.png) | [png](https://github.com/QRun-IO/qbit-cms/blob/feature/phase-1-skeleton/docs/screenshots/core/sites-home-dark-390.png) | [png](https://github.com/QRun-IO/qbit-cms/blob/feature/phase-1-skeleton/docs/screenshots/core/sites-home-light-1440.png) | [png](https://github.com/QRun-IO/qbit-cms/blob/feature/phase-1-skeleton/docs/screenshots/core/sites-home-dark-1440.png) |
| Page record (sections list) | [png](https://github.com/QRun-IO/qbit-cms/blob/feature/phase-1-skeleton/docs/screenshots/core/page-record-light-390.png) | [png](https://github.com/QRun-IO/qbit-cms/blob/feature/phase-1-skeleton/docs/screenshots/core/page-record-dark-390.png) | [png](https://github.com/QRun-IO/qbit-cms/blob/feature/phase-1-skeleton/docs/screenshots/core/page-record-light-1440.png) | [png](https://github.com/QRun-IO/qbit-cms/blob/feature/phase-1-skeleton/docs/screenshots/core/page-record-dark-1440.png) |
| Menu record (items list) | [png](https://github.com/QRun-IO/qbit-cms/blob/feature/phase-1-skeleton/docs/screenshots/core/menu-record-light-390.png) | [png](https://github.com/QRun-IO/qbit-cms/blob/feature/phase-1-skeleton/docs/screenshots/core/menu-record-dark-390.png) | [png](https://github.com/QRun-IO/qbit-cms/blob/feature/phase-1-skeleton/docs/screenshots/core/menu-record-light-1440.png) | [png](https://github.com/QRun-IO/qbit-cms/blob/feature/phase-1-skeleton/docs/screenshots/core/menu-record-dark-1440.png) |
## Not in this slice
- **MEDIA and BLOG**, the rest of phase 1. MEDIA brings `CmsStorage` with `ObjectStorageCmsStorage` from qbit-object-storage (D17). Then PROJECTS, PHOTOS, PUBLISHING and STREAM (phase 2).
- **ESB table events and Quick Search.** Both are optional integrations and arrive with phase 2, per the spec's section 2.8.
- **Public visibility locks.** These arrive with the Site API artifact. That artifact generalizes Website-Backend's Site API v2 generator (#123) instead of porting v1 (spec build alignment).
- **Open-reads metadata.** QQQ's `CustomPermissionChecker` does not receive the permission sub-type, so with `openReads` the table metadata reports writes as allowed. They are still refused when run. kof22's #107 checker has the same limit. A small QQQ core change would fix this.
## Owner steps
- Follow the project in CircleCI and add a GitHub user key. Until then the required `test_only` check cannot run, so this PR cannot merge.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
What
Phase 1 of the qbit-cms spec (QRun-IO/qqq#1083, tracking #1094), first vertical slice: the qbit skeleton with feature flags, and the CORE feature end to end. CORE covers sites, domains, redirects, pages, page sections, snippets and menus.
CmsQBitProducer/CmsQBitConfig. Builds everything from the config, with no package scan. It validates the config (RDBMS backend, prefixes, feature dependencies, parent app). A namespace sets the prefixes for a second install. It refuses names that are already on the instance.CmsFeatureflags with dependencies (BLOG requires MEDIA, PUBLISHING requires BLOG or PROJECTS, and so on). Only CORE is implemented; the other features are rejected until they ship, so a host never believes it installed something that is not there.CORE tables as standard QQQ tables. The names are
cms_plus lowercase snake_case, and the QQQ name equals the SQL name. Each table has snake_case columns, a possible-value source and permission rules, plus field audit when the host has the QQQ audit tables. Child tables (domains, page sections, child pages, menu items) are child lists on their parents and are not in the nav.page.sectionsandmenu.itemsare associations, so nested writes work.Admin. One CMS app with a home, and one sub-app per area (Sites, Pages, Navigation), each with its own home. The top home shows each area's totals. An area home shows what needs attention (unpublished or inactive rows), the create actions the user may run, and recent changes. It does not repeat the table counts that Next's app page already lists. Every read runs as the user (
PermissionEnforcement.ENFORCE, because widgets render inside a trusted scope), so a table the user may not read is left out instead of shown as zero.Security.
CmsRolebundles (ADMIN, EDITOR, PUBLISHER, DRAFTER, VIEWER),CmsSecurity.applySessionPermissions(session, groups, config)for the host's authentication customizer, andopenReads(kof22's current behaviour).Schema (D5). A prefix-parameterized Liquibase changelog that the host includes and runs (
qbit-cms/db/changelog/qbit-cms.changelog.yaml, logical pathqbit-cms/changelog):cms.tablePrefix,cms.featuresandcms.seedReferenceDataproperties;Reference DDL is in
docs/ddl/postgres.sql.Install doctor. A startup schema check with
startupModeFAIL_FAST(default),DEGRADED(shown on the CMS home) orSKIP.kof22 schema match
I ran the changelog on PostgreSQL 17 next to a database built from Website-Backend's own migrations (through 0024, the
cms_rename) and diffedinformation_schema.columnsfor the 11 CORE tables. Every column matches on name, type, length and nullability, with two expected exceptions:createdate/modifydatearecreate_date/modify_datehere (spec build alignment: kof22 renames them in its cutover changeset);cms_pagebelong to the MEDIA feature (next slice).Constraint names are readable (
fk_cms_page_site_id) where kof22 has Hibernate hash names; kof22's cutover renames them beforechangelog-sync(spec section 3.6.1).Tests
./mvnw verify: 23 tests, 0 failures; Checkstyle (now wired intovalidate) 0 violations; JaCoCo 97.8% instructions, 100% classes.CmsInstallTest(H2)cms_pagehalts and is left untouched, seeds off, FAIL_FAST and DEGRADED without the changelog or without CORECmsPostgresInstallTest(Testcontainers, PostgreSQL 17)CmsQBitProducerTestCmsSecurityTestCmsHomeRendererTestCmsChildListTestCmsEntitiesTest,CmsFeatureTestScreenshots
These come from a blank QQQ app (
CmsDemoServer, test scope) on PostgreSQL with kof22's real CORE content (site, domains, 6 pages, 13 sections, 6 snippets, 2 menus, 23 items), in Next UI 1.0.0-RC.14. There is no horizontal overflow at either width.Not in this slice
CmsStoragewithObjectStorageCmsStoragefrom qbit-object-storage (D17). Then PROJECTS, PHOTOS, PUBLISHING and STREAM (phase 2).CustomPermissionCheckerdoes not receive the permission sub-type, so withopenReadsthe table metadata reports writes as allowed. They are still refused when run. kof22's #107 checker has the same limit. A small QQQ core change would fix this.Owner steps
test_onlycheck cannot run, so this PR cannot merge.🤖 Generated with Claude Code
View command line instructions
Checkout
From your project repository, check out a new branch and test the changes.