Independent static sites with Site Publish
Site Publish lets a tenant edit its independent site in k0smos and explicitly
publish a static release. The supplied DM3 landing is the first profile; the
generic content profile publishes public Page, Blog, Menu and Media data for
another independent frontend through the same pipeline. The DM3 site serves
prerendered HTML, React hydration, local fonts, images and MP4 clips; another
profile's independent repository owns its rendering stack. Opening a built site needs no PHP,
tenant database, Higgsfield Worker or public content API.
The initial profile preserves the existing six material chapters and single
scroll-controlled film. It publishes Italian at /; English administration
does not imply an English public page. The site checkout has its own Git
repository, dependency lock and tooling. It is not a k0smos theme.
Ownership and operator access
site_publish is optional and disabled by default. Enable it explicitly with
its Media dependency after installing the module migrations. Page is optional
for dm3 legal content and required when selecting content; Blog is optional
inside content and must be active when selected.
| Owner | Canonical data |
|---|---|
| Site Publish | Target binding, bounded profile configuration, immutable document revisions and publication ledger |
| Media | Original bytes, publication state, trusted paths, MP4 validation and image metadata |
| Company settings | Public company identity and explicitly allowlisted social links |
| Page | Selected DM3 legal documents or exact-locale generic Page inventory; drafts and email templates are excluded |
| Independent site | Components, CSS, projection schema, static builder and release activation |
/admin/site-publish requires site_publish.view. Editing, publication, retry,
rollback and recovery require site_publish.manage; cookie-authenticated
mutations require the site_publish.admin CSRF token. The Media picker retains
media.view. Import additionally requires media.upload and media.edit only
when the selected profile imports source assets (including dm3); the generic
content profile imports no Media because it reads already-published owner data.
Site Publish never grants those permissions itself.
The Default/Sober editor includes header links and logo, scene copy, desktop and mobile video/poster pairs, all six chapters, materials and manufacturer source links, contact action, SEO and up to four legal-page selections. Company values are read-only in this panel and remain editable through their existing owner. Async validation preserves browser input; a stale revision returns HTTP 409. The module editor is server-rendered and this profile addition requires no theme bundle build. Theme asset builds remain part of the maintainer's normal release procedure when theme-owned sources change.
Video replacement requires its paired exact-frame poster, with matching
dimensions. Desktop MP4s allow 32 MiB and mobile MP4s 16 MiB. Media validates the
self-contained H.264 container before trusted storage. The website contact link
remains the default; WhatsApp needs a validated international + number and
generates an ordinary outbound link. There is no lead submission backend.
Generic public content
Select "profile": "content" for an independent frontend that consumes the
tenant's current public content rather than a bespoke landing document. Its one
revisioned configuration record contains:
- Page enabled as the required owner and Blog as an explicit optional owner;
- one published home Page slug for every
i18n.supported_localesentry; front.primaryand/orfront.footerpublished menu placements; and- up to 50 internal redirects whose target exists in the same generation.
The default locale is unprefixed. Other locales use /<locale>/. Page exports
published ordinary exact-locale rows; Blog uses published base rows for the
default locale and requires an exact translation row for every additional
locale. Missing active owner modules, home Pages, duplicate routes/documents,
redirect collisions and oversized inventories fail the complete publication.
No partial generation is emitted.
Page and HTML-format Blog bodies cross a semantic sanitizer. It preserves ordinary headings, paragraphs, lists, quotes, code, tables, safe links and public Media images while excluding scripts, forms, embeds, SVG/MathML, inline styles, editor classes and unknown images. Plain Blog bodies are escaped. Page CSS and project JSON, Blog editor metadata, k0smos templates and credentials are never exported. The independent frontend owns all presentation and CSS.
Published anonymous menus contain localized labels, nested children and only links that resolve to this generation or a safe HTTPS, mailto or fragment target. Referenced public images are copied by checksum with their already- generated responsive WebP derivatives; publication does not generate variants. Portable payloads contain no tenant Media IDs or private paths.
Deployment configuration
Keep the tenant's root database block unchanged. Only the independent checkout
location, HTTPS public origin and optional content profile belong in its
deployment-only site block:
{
"site": {
"root": "site/dm3",
"public_url": "https://k0smos.example.com"
}
}
The resolved tenant is the entry point for everything below. Its declared folder
name is the published site code (site/dm3 publishes dm3), and the content
profile defaults to that code. Set profile when the checkout name and contract
differ; for example, a generic site may declare:
{
"site": {
"root": "site/showroom",
"public_url": "https://k0smos.example.com",
"profile": "content"
}
}
dm3 and content are both registered. The resolved code and profile are
frozen in the module's target row at
first import, so a later rename or profile change resolves to a different site or
fails closed rather than republishing this one under a new name.
Editing depends on the declaration only, never on the checkout being present, so
content work continues while a site folder is unmounted or replaced. Publication
fails closed until the checkout resolves, and the editor panel, site:status and
the CLI report the exact deployment error.
Optional Supabase/PostgreSQL projection
site.sqlite remains the canonical, sufficient generation database. A
deployment that wants a Supabase-backed site reader may add a separate
server-side PostgreSQL target without changing the tenant's root database:
{
"site": {
"root": "site/showroom",
"public_url": "https://k0smos.example.com",
"profile": "content",
"database": {
"driver": "supabase",
"url": "${K0SMOS_SITE_SUPABASE_DATABASE_URL}"
}
}
}
The environment value is a PostgreSQL connection URL for a server-side writer,
not an anon browser key. Remote Supabase connections require
sslmode=require. Literal credentials must not be committed to tenant JSON and
never enter AppSettings, the public manifest, build receipts, logs or generated
assets.
Before enabling the target, apply
modules/SitePublish/resources/schema/v1.pgsql.sql with the deployment's
qualified schema owner. Runtime publication performs no DDL. The schema marker
must remain version 1; every generation data table is namespaced by exact site
and generation, and RLS has no public policy by default. A direct writer needs the
deployment-approved protected-table role. If PostgREST/browser reads are later
enabled, add narrowly reviewed site-specific SELECT policies and never expose a
service credential in the browser.
After the local manifest is finalized, Site Publish verifies its ledger
identity and SQLite digest, then inserts all portable rows into PostgreSQL in
one transaction before the independent-site validation/build. Repeating the
same manifest digest is safe; a conflicting generation ID, sequence, schema or
digest fails closed. A PostgreSQL outage fails that publication attempt without
modifying the immutable SQLite generation. Omitting site.database keeps the
current SQLite-only path unchanged. A live Supabase account and executable
SQLite/PostgreSQL parity test are not prerequisites at this stage.
Relative roots resolve below the k0smos checkout. By default, a target must be
a strict descendant of its site/ directory. For a separately located checkout,
set K0SMOS_SITE_ALLOWED_ROOTS to approved absolute parent directories, separated
by the platform's path separator. Canonical-path checks reject source/public
overwrites, a target containing the tenant database, unsafe links and path
escapes. The root must already contain the site's app, contracts and
tooling directories.
Locale policy, revisions and status are module-owned database state.
The target stores an opaque source identity separately from its internal tenant
binding. A private site-side binding must match before publication. Bun must be
available to the worker; the adapter uses fixed arguments, app/ as its working
directory, bounded execution time/output and an allowlisted process environment.
Credentials and arbitrary editable shell commands are not passed to the build.
Choose the document root deliberately
The two public surfaces remain independent even when they share one hostname:
| Web-server target | Result for / |
|---|---|
k0smos public/ |
Runs public/index.php and renders the tenant's configured theme.front |
site/<site>/current |
Serves the activated independent static release |
The site.root declaration tells Site Publish where to prepare and activate the
independent checkout. It does not change k0smos routing and does not make the
independent site a theme. Pointing a virtual host at the k0smos repository root
is unsupported; the PHP application document root is always its public/
directory.
Do not use site/<site>/app/public as the independent production document root.
That directory is owned by the site's source application and supplies bounded
import assets. A build writes an immutable releases/<generation>/public
directory, and activation atomically moves current to that release. The web
server must also map /assets/ and /site-media/ to their matching directories
under published-assets/; serving current alone loses the retained-asset
contract used by old browser tabs and rollback.
Bun is a build-time dependency of the queue worker and explicit site:build
command. Once a generation is active, Apache, Caddy or another qualified static
server reads ordinary files; no Node, Deno or Bun HTTP process is required.
First import and backup
Use the existing target tenant. The examples use the canonical placeholder
hostname; substitute the tenant's configured host or numeric ID. --actor=7
illustrates an existing authorized user and must be replaced with the real
operator ID. Module commands are available only when the module is active.
php bin/console site:import --host=k0smos.example.com --actor=7 --dry-run
php bin/console site:import --host=k0smos.example.com --actor=7 --confirm-checksum=PREVIEW_SHA256
For dm3, preview checks the supplied document and all seven original assets. A
content checkout instead supplies contracts/content.import.json with an empty
assets collection and a bounded configuration document. Preview reports the
exact combined checksum, counts and occupied document status. Apply requires
that unchanged checksum. The current SQLite adapter creates a consistent backup
under private var/backups/site-publish/ before first import; backups are not
included in customer handoffs. Other tenant database platforms require a
qualified backup adapter before import. Archive the preview and backup according
to the deployment's retention policy.
Import uses Media's owned ingestion path and rebinds portable asset references. It inserts missing target/document records only. Repeating it preserves operator edits and occupied keys. This operation imports the supplied site; it is not a demo fixture and does not activate the site.
Save, publish, build and recover
The panel reports four distinct facts: saved editorial revision, exported
revision, recorded live generation and independently verified live generation.
Saving does not update the public website. Publishing records a complete-
generation request and queues its identity on the logical default queue. The
worker captures the owner-consistent SQLite/media snapshot, builds it and
activates the verified release under the target lock. Frontend compilation and
activation are never a synchronous HTTP fallback. Keep the ordinary tenant
queue worker running; see Queue operations.
php bin/console site:publish --host=k0smos.example.com --actor=7
php bin/console site:status --host=k0smos.example.com
php bin/console queue:topology --host=k0smos.example.com
TENANT_ENV=k0smos.example.com php bin/console queue:work --queue=default --worker-slot=1
States progress through requested, preparing, ready, building, validated and active; failures remain visible. Every publication gets a new sequence even if only company settings or Media changed. A late older worker cannot activate after a newer request. A failed capture or build preserves the previous release.
php bin/console site:publish --host=k0smos.example.com --actor=7 --retry=generation_001
php bin/console site:build --host=k0smos.example.com --actor=7 --generation=generation_001
php bin/console site:rollback --host=k0smos.example.com --actor=7 --generation=generation_001
php bin/console site:status --host=k0smos.example.com --actor=7 --reconcile
site:build is an explicit operator alternative to queue processing for one
requested or frozen generation; when capture is still pending it performs that
capture before building. Retry reuses a complete checked generation or requests a new
capture when preparation never completed. A validated release whose activation
failed can be queued again without recompiling; the editor offers Retry until
its first activation. Previously activated releases instead offer Rollback,
and duplicate retry requests cannot undo an operator's rollback.
Rollback selects a retained validated
release without reverting edited content. Recovery compares the live release
marker and checksums with the ledger after an interrupted activation; it never
assumes that a successful export is online. The full command inventory is in
Console and Composer commands.
Portable contract and release storage
The independent repository owns contracts/schema-v1.sql, its sample/import
manifests and contracts/README.md. Only explicit site tooling initializes the
projection schema; the publisher inserts validated data. Build processes open
the completed projection read-only and never query the changing tenant DB.
data/source-binding.json
data/generations/<generation>/
manifest.json
site.sqlite
media/
releases/<generation>/
release.json
public/
published-assets/
assets/
site-media/
current -> releases/<generation>/public
Schema version 1 carries the tenant's resolved site code, opaque source identity,
generation ID, monotonic sequence, document revision, timestamp, public origin,
locale/route inventory and SHA-256 checksums. SQLite contains generation metadata,
explicit public settings/navigation, documents under profile-owned portable
keys, selected legal pages, redirects and referenced Media. dm3 writes one
dm3 document and leaves redirects empty. content writes its site descriptor
plus page/<slug> and blog/<slug> rows per locale and uses the redirect table.
There are no private credential or raw editor-project tables.
The optional PostgreSQL v1 schema carries the same logical records, adding
site_code and generation_id to every shared row so retained generations are
append-only and selectable without overwriting one another. The independent
site's data layer keeps SQLite as the default and may select one exact
site/generation from PostgreSQL behind the same portable reader contract.
The publisher captures matching content and Media bytes, verifies copies and
writes the manifest last. A committed generation is never overwritten. Required
missing/private Media and malformed/checksum-mismatched input fail closed. A
selected legal page that is no longer published disappears from the next complete
generation. Legal routes are /legal/<slug>/; their renderer accepts semantic
text and safe links, without arbitrary CSS, scripts or embedded assets.
Activation verifies the release and copies immutable assets into the append-only
published-assets pool before switching current. Copies are checked in private
staging on the same filesystem and installed by atomic rename. Interrupted
copies cannot leave incomplete public assets; isolated partial-write and full-disk
error injection verify that activation can be retried. Serve /assets/ and
/site-media/ from that pool so browsers holding older HTML can still load their
files after an activation or rollback. Retain the pool with all referenced
releases; V1 has no automatic cleanup command. Linux activation uses atomic
symlink replacement. Windows activation remains unsupported until its adapter
is qualified.
Same-host routing
The reviewable DM3 Caddy profile at deploy/sites/Caddyfile.dm3 and the generic
Apache template at deploy/sites/apache.site-publish.conf keep independent
static roots separate from k0smos public/. The Apache template contains no
tenant or profile name. Include it from a deployment-specific virtual-host file
after defining SITE, ALIAS, MAIL, LOG, CERT, KEY, PHP,
K0SMOS_ROOT, SITE_PUBLISH_ROOT, K0SMOS_BACKEND_PORT,
SITE_PUBLISH_HTTP_PORT and SITE_PUBLISH_HTTPS_PORT as documented in its
header. It creates a private loopback k0smos backend and preserves the original
Host for tenant resolution. The Caddy example instead uses
DM3_SERVER_NAME, DM3_SITE_ROOT and K0SMOS_BACKEND.
Neither server is a runtime requirement. A qualified static server must use the
activated current directory for ordinary HTML, alias /assets/ and
/site-media/ to published-assets/, apply the documented cache/range/security
headers, deny private checkout paths, and return real static 404s. For the
k0smos route inventory it may either:
- reverse proxy to a private k0smos PHP HTTP listener, preserving
Host; or - execute k0smos
public/index.phpdirectly through PHP-FPM with explicit aliases/rewrites for those backend paths.
Neither Apache arrangement proxies to a JavaScript development server. Do not apply k0smos's general PHP front-controller fallback to the static site: an unknown public path must not silently enter PHP.
The explicit backend route inventory covers admin/kiosk, API, login/logout/auth,
OAuth and its well-known metadata, native /media, admin /build assets and
supported localized forms. Review configured private-login and provider callback
routes against the deployment before activating the profile. Public /, legal
pages, robots/sitemap/404 and independent assets use the static roots. /app,
unknown routes and missing assets return real 404s; there is no SPA or PHP
catch-all rewrite.
The static server owns correct MIME/range responses, immutable asset caching,
HTML revalidation and the CSP, including media-src blob: for the preserved
scrub controller. Deny source, .git, environment files, private generation
manifests, SQLite/WAL/SHM and unactivated inputs. The current landing uses no
optional tracking or embedded provider; outbound manufacturer/WhatsApp links
do not load an SDK.
Hosting-configuration backup/restore is deployment policy, not a Site Publish acceptance requirement. It remains separate from release rollback. Static delivery survives unavailable PHP endpoints when the static server remains running. A colocated server still shares hardware, disk, network and possibly the web-server process; stopping that infrastructure is not the application- outage test.
Current tenant qualification — 2026-09-21
Tenant 33 was resolved from its unique existing descriptor and SQLite
database; the root database configuration was preserved. After a private
consistent backup and checksum-confirmed import, revision 1 was published as
generation g20260921135800-0d4666306d53d2ab. Its source manifest digest is
1daed1bd82021e34f5811af050aa497a22a189f5e7773f4a446c1eff9063a5d0 and
its release digest is
12cdd599cc851eff5afe2b294e46436f5a3c0da662cd85ffd378785c7f4fed5c.
Status independently verifies that release as active.
The Apache profile first passed syntax, route, denial, static 404, immutable
cache, MP4 range, CSP and PHP-outage checks on private ports using the active
release and real tenant routes. A privileged operator then switched the actual
Apache listeners on 2026-09-21. HTTP and
HTTPS root responses match the active release byte-for-byte; real static 404s,
private-path denials, immutable CSS, MP4 range delivery and the explicit
admin/login/OAuth backend routes pass with the tenant Host and TLS SNI. The
canonical hostname is not resolvable from this workspace, so external DNS and
network reachability are not inferred from the server-side listener checks.
The active deployment now includes the generic
deploy/sites/apache.site-publish.conf; repeat probes after that migration
returned the same release hash and routing results.
The controlled backend-unavailable probe passed on the public listener: with a
deliberately nonexistent PHP-FPM socket, root HTML retained its exact active
release hash, immutable CSS remained 200, a real MP4 range remained 206 and
the physical static 404 remained 404, while admin, login and OpenID returned
503. This proves separation from the PHP endpoint only. Apache, storage,
hardware, network and the host are shared failure domains and provide no
independent availability guarantee. The owner explicitly removed the old-vhost
backup/restore exercise from this deployment's acceptance scope; release
rollback remains a separate Site Publish operation.
After restoring the production PHP-FPM socket and reloading Apache, root retained
the same active-release SHA-256, anonymous admin routes again redirected to their
matching login routes, and login plus OpenID returned 200. No static release
activation or rollback was involved in the outage or recovery.
Optional customer-hosted pull
A customer host that cannot accept inbound deployment connections can pull one
already validated generation from k0smos. This is a private machine-to-machine
surface, not a browser content API and not part of the public OpenAPI document.
It is an available optional transport, not a required deployment workflow;
deployment-specific scripts or Git branches may be used instead.
All routes require the exact site_publish.pull permission and a personal API
token that is explicitly scoped, has a future expiry and belongs to the live
user. Full-authority legacy tokens, unscoped tokens, permanent tokens, JWTs and
login cookies are deliberately insufficient.
Grant site_publish.pull only to the dedicated operator account used for that
customer site. That user can issue its own token through
POST /api/user-settings/api-tokens with an explicit expiry:
{
"name": "customer-site pull",
"scopes": ["site_publish.pull"],
"expires_in_days": 30
}
The raw k0s_pat_... value is returned once. Store it only in the customer
host's secret manager or root-readable environment file; never put it in tenant
JSON, site source, generated assets, command arguments, URLs or build output.
The pull path is classified as sensitive before routing, so application debug,
SQL and request collectors exclude its headers and response bodies. Deployment
proxy access logs remain the operator's responsibility and must not record
Authorization headers.
Given the operator-selected site code and generation ID, the runner uses:
| Method | Private route | Result |
|---|---|---|
GET |
/api/site-publish/pull/<site>/<generation>/snapshot |
Raw, frozen manifest.json |
GET |
/api/site-publish/pull/<site>/<generation>/database |
The manifest-listed site.sqlite |
GET |
/api/site-publish/pull/<site>/<generation>/media/<asset> |
One exact manifest-listed Media file |
POST |
/api/site-publish/pull/<site>/<generation>/acknowledgements |
Idempotent proof of activated manifest/release digests |
Every request revalidates PAT state, user association, tenant-local site binding,
generation state, source identity and manifest checksum. Downloads are available
only for validated or active generations. Database and Media names must be
listed in that immutable manifest, and the bytes are checksum-verified again on
the opened stream before the private no-store response is sent. A route from one
tenant cannot name a target in another tenant because authentication, target and
generation ledgers all resolve from the current tenant database.
The customer runner must stage into a new private directory, reject an unknown schema or identity, download only the manifest inventory, verify every SHA-256, then invoke the site repository's own validate/build operations. It activates the new release atomically only after the build receipt matches the selected generation. Finally it posts exactly:
{
"manifest_digest": "<64 lowercase hex>",
"release_digest": "<64 lowercase hex>"
}
The first identical acknowledgement returns 201; retries return 200 and the
original timestamp. A different digest conflicts and cannot replace the proof.
k0smos stores only the token row ID and associated user ID, never the raw token.
The independent checkout owns the actual runner and release layout; restore its
documented tooling/ and contracts/ files before enabling this mode.
Rotate by issuing a second short-lived scoped token, testing one complete pull,
then revoking the old token through
DELETE /api/user-settings/api-tokens/<id>. Revocation is checked on the next
request, and removing the permission or disabling the user also stops access.
Keep the prior customer release active throughout rotation. The customer owns
backups of its site repository, frozen inputs and release pointer; restoration
selects a previously verified local release and does not require access to the
k0smos tenant database.
Retain generations and checksum-addressed Media for at least every rollbackable customer release. No automatic generation or Media cleanup is shipped, so a site-specific, checksum-aware retention policy must be proven before deleting anything. During a k0smos/PHP outage, the last activated customer release stays live and new publication waits; do not retry activation from incomplete staging. Shared customer hardware, storage, network and static-server failures remain a separate availability domain.
The same client-host boundary applies to deployment-specific scripts or Git
branches: serve only the activated static release, mount retained hashed files
at /assets/ and /site-media/, and keep all staging, source and release metadata private.
site.sqlite is a build input and is optional at runtime unless that independent
frontend explicitly chooses a read-only server/runtime adapter for it. No client
host needs PHP or access to the k0smos database.
Customer handoff and verified scope
From the independent checkout, the site-owned commands rebuild and serve a saved generation without PHP or k0smos:
bun tooling/site.ts validate --generation generation_001
bun tooling/site.ts build --generation generation_001
bun tooling/site.ts serve --generation generation_001 --port 4173
bun tooling/site.ts handoff --generation generation_001 --destination /tmp/dm3-customer
Handoff includes source, lockfile, selected local generation/assets, licenses and
operating instructions. It excludes the editorial database and private backups.
Install locked dependencies in app/ using bun install --frozen-lockfile; an
uncached first install requires the package registry. Preserve original asset
rights and upstream notices; possession of an export does not establish new
redistribution rights.
Isolated PHPUnit checks cover migration-backed revisions, target isolation, stale-worker fencing and rollback. Real-kernel checks cover cookie authentication, ACL/CSRF, saves, conflicts, invalid media pairs and scoped/expiring pull-token revocation. Pull service checks cover tenant/site/generation fencing, manifest- listed streaming, tamper refusal and idempotent digest acknowledgement. Real ThemeEngine renders cover Default/Sober in IT/EN. The independent repository records its static browser, build, outage and handoff evidence separately.
As of 2026-09-15, tenant ID 33 was resolved uniquely from configuration, but its
configured var/db/33.sqlite was absent in this workspace. The target was not
imported or activated. Apache was present and no matching inspected virtual host
established the final topology; the running web-server configuration was left
unchanged. Local evidence does not establish tenant-specific live acceptance or
a production Caddy reload.