Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,27 @@ permissions:
jobs:
test:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:17-alpine
env:
POSTGRES_HOST_AUTH_METHOD: trust
ports: ['5432:5432']
options: >-
--health-cmd "pg_isready -U postgres"
--health-interval 5s
--health-timeout 5s
--health-retries 10
env:
CMS_TEST_DATABASE_URL: postgres://postgres@127.0.0.1:5432/postgres?sslmode=disable
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
- name: Verify exact tested source
env:
EXPECTED_TEST_SHA: ${{ github.event.pull_request.head.sha || github.sha }}
run: test "$(git rev-parse HEAD)" = "$EXPECTED_TEST_SHA"
- uses: actions/setup-go@v6
with:
go-version-file: go.mod
Expand Down
14 changes: 9 additions & 5 deletions adminui/static/admin.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,10 @@ const api = {
});
if (!res.ok) {
const text = await res.text();
if (res.status === 409) {
let error; try { error = JSON.parse(text); } catch {}
if (error?.error === "version_conflict") throw new Error("This page changed. Your draft is still here. Keep a copy of your changes, then reload to review the latest page before saving.");
}
throw new Error(method + " " + path + " → " + res.status + ": " + text);
}
if (res.status === 204) return null;
Expand All @@ -29,7 +33,7 @@ const api = {
getPage(tid, pid) { return this.req("GET", "/tenants/" + tid + "/pages/" + pid); },
createPage(tid, b) { return this.req("POST", "/tenants/" + tid + "/pages", b); },
updatePage(tid, pid, b) { return this.req("PUT", "/tenants/" + tid + "/pages/" + pid, b); },
deletePage(tid, pid) { return this.req("DELETE", "/tenants/" + tid + "/pages/" + pid); },
deletePage(tid, pid, version) { return this.req("DELETE", "/tenants/" + tid + "/pages/" + pid, {expected_version:version}); },
permissions(tid, pid) { return this.req("GET", "/tenants/" + tid + "/pages/permissions" + (pid ? "?page_id="+pid : "")); },
templates(tid) { return this.req("GET", "/tenants/" + tid + "/pages/templates"); },
async preview(tid, body) {
Expand Down Expand Up @@ -121,7 +125,7 @@ async function applyView(route){
const form=$("#page-editor-form");form.reset();pageField(form,"id").value=p.ID||"";
for(const [field,key]of Object.entries({path:"Path",title:"Title",status:"Status",template_id:"TemplateID"}))pageField(form,field).value=p[key]|| (field==="status"?"draft":"");
pageField(form,"publish_at").value=isoToDateTimeLocal(p.PublishAt);pageField(form,"unpublish_at").value=isoToDateTimeLocal(p.UnpublishAt);
const body=p.RenderedBodyHTML??p.BodyHTML??"";setRichEditorHTML(form,body);savedPageBodies.set(form,{html:body,blocks:p.BodyBlocks});pagePermissions=permissions;draftKey=pageKey(route);baselineDraft=JSON.stringify(pagePayload(form));
const body=p.RenderedBodyHTML??p.BodyHTML??"";setRichEditorHTML(form,body);savedPageBodies.set(form,{html:body,blocks:p.BodyBlocks,version:p.Version});pagePermissions=permissions;draftKey=pageKey(route);baselineDraft=JSON.stringify(pagePayload(form));
$("#page-templates").replaceChildren(...(templates.templates||[]).map(name=>new Option(name,name)));
$("#editor-tenant").textContent=(tenant.Label||tenant.Slug)+" · "+tenant.Slug;$("#editor-title").textContent=p.Title||"New page";
const pages=$("#pages-link");pages.dataset.route="/tenants/"+tenant.ID+"/pages";pages.href=cmsBase+pages.dataset.route;
Expand Down Expand Up @@ -497,14 +501,14 @@ document.addEventListener("DOMContentLoaded",()=>{
if(!form.checkValidity()){await navigate(pagePath(route,"publishing"));form.reportValidity();return}
const payload=pagePayload(form);saving=true;freezeDraft(true);$("#btn-save-page").disabled=true;updateSaveState();
try{
const saved=route.pid==="new"?await api.createPage(tid,payload):await api.updatePage(tid,route.pid,payload);
const saved=route.pid==="new"?await api.createPage(tid,payload):await api.updatePage(tid,route.pid,{...payload,expected_version:savedPageBodies.get(form)?.version});
if(key!==draftKey||tid!==currentTenant?.ID||generation!==navigationGeneration)return;
savedPageBodies.set(form,{html:payload.body_html,blocks:payload.body_blocks});baselineDraft=JSON.stringify(payload);$("#editor-title").textContent=payload.title;toast("Page saved", "ok");
savedPageBodies.set(form,{html:payload.body_html,blocks:payload.body_blocks,version:saved.Version});baselineDraft=JSON.stringify(payload);$("#editor-title").textContent=payload.title;toast("Page saved", "ok");
if(route.pid==="new"){freezeDraft(false);saving=false;discardDraft();await navigate("/tenants/"+tid+"/pages/"+saved.ID+"/content",true)}
}catch(e){if(key===draftKey&&generation===navigationGeneration)fail(e)}finally{freezeDraft(false);saving=false;$("#btn-save-page").disabled=false;updateSaveState()}
};
$("#btn-page-preview").onclick=()=>refreshPreview().catch(fail);
$("#btn-delete-page").onclick=async()=>{if(saving||!pagePermissions.delete||!confirm("Delete this page?"))return;const tid=currentTenant.ID,pid=currentRoute.pid; saving=true;try{await api.deletePage(tid,pid);discardDraft();saving=false;await navigate("/tenants/"+tid+"/pages")}catch(e){fail(e)}finally{saving=false;updateSaveState()}};
$("#btn-delete-page").onclick=async()=>{if(saving||!pagePermissions.delete||!confirm("Delete this page?"))return;const tid=currentTenant.ID,pid=currentRoute.pid; saving=true;try{await api.deletePage(tid,pid,savedPageBodies.get($("#page-editor-form"))?.version);discardDraft();saving=false;await navigate("/tenants/"+tid+"/pages")}catch(e){fail(e)}finally{saving=false;updateSaveState()}};
}else{
$("#new-tenant-form").onsubmit=async ev=>{ev.preventDefault();const fd=new FormData(ev.target);try{await api.createTenant({slug:fd.get("slug"),label:fd.get("label"),theme_id:fd.get("theme_id")});ev.target.reset();toast("Tenant created","ok");await navigate("/tenants")}catch(e){fail(e)}};
$("#new-domain-form").onsubmit=async ev=>{ev.preventDefault();const tid=currentTenant.ID,generation=navigationGeneration,fd=new FormData(ev.target);try{await api.createDomain(tid,{host:fd.get("host"),subsite_label:fd.get("subsite_label"),kind:fd.get("kind")});if(generation!==navigationGeneration)return;ev.target.reset();toast("Domain added","ok");await refreshDomains(tid,generation)}catch(e){fail(e)}};
Expand Down
114 changes: 114 additions & 0 deletions docs/plans/2026-10-08-content-promotion-amendment.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
# Content snapshot promotion implementation amendment

This additive CMS implementation follows the parent-authorized Tina publication
assessment in multisite `docs/runbook/tina-content-promotion.md`. It preserves
the locked Tina plan, CMS PR31 at `0ceed5592a335e16b2eb6ddc5148ef45f248d0a7`,
CMS main's separate editor changes, all consumer pins and active worktrees.

## Scope and boundaries

1. Typed content-only snapshot export/strict decoding, explicit target mappings,
deterministic dry-run diffs and immutable bundle verification.
2. Whole-tenant page baseline guarded atomic batches and content-only before/after
receipts. Guarded rollback refuses any intervening page mutation.
3. All ordinary CMS creates/updates/deletes serialize with batches. Update uses
the caller's loaded `Page.Version`; API save/delete requires the client's
`expected_version`, and the editor retains its draft on conflict.
4. Memory/API tests plus a real isolated Postgres schema prove stale saves,
racing writes, all-or-nothing failure, mappings, omission and rollback.

There is no promotion HTTP route, production write, credential configuration,
host deployment, consumer-pin change or storage purchase in this amendment.
Passwords, hashes, tokens, domains, roles, memberships, sessions and repository
mapping are outside every promotion struct. Unknown JSON fields and malformed
payloads yield fixed errors without printing supplied values.

`PageStore.Delete` gains a required expected-version argument. This source API
change and the mandatory HTTP precondition require a coordinated host/plugin
release; consumers must not discard the browser's version or fall back to an
unconditional write. The historical PR31 editor remains unchanged in its own
worktree.

## Concurrency contract

Postgres page mutations take one transaction-scoped advisory lock per tenant.
Batches read and compare the complete tenant page ID/version/content baseline
under that same lock and commit all mutations in one transaction. The lock is
cooperative, not an atomic cloud-spec CAS or a promise covering arbitrary direct
SQL writers. Per-row version predicates protect stale ordinary saves/deletes
after promotion releases the lock. No omitted page is deleted.

Rollback compares the complete recorded post-state and restores only affected
content, retaining page IDs and increasing versions. Backups are content only;
their durable archive is the authorized publication operator's responsibility.

## Publication remains disabled

CMS database transactions cannot atomically switch filesystem bundles or cloud
bootstrap configuration. A live publication endpoint remains blocked on a host
write/publication fence, staged immutable bundle, verified bootstrap source,
durable backup, superadmin authority, approval digest and restart/redeploy proof.
Uploaded tenant media references are refused until durable copy/rewrite/retrieval
exists. Static files shadowing selected CMS routes refuse validation.

## Verification and resource ownership

Use the existing module/build caches, `GOWORK=off`, package-targeted tests with
`-p 2` at most. Parent authorized one focused local allocation and a fresh
`promotion_20261008_*` schema on existing task Postgres port 55432. No Docker,
new cache, full local race build, existing-schema mutation or process restart.
CI may run its existing full checks plus an isolated Postgres service. Record
exact-head checks and obtain independent source review before merge.

## Independent-review correction: durable mutation generation

Review of `f7fd874` found a delete/restore/edit/delete ABA hole: surviving page
rows alone cannot detect a changed-then-empty tenant. This additive correction
introduces a durable `cms_page_revisions` ledger with a unique content-scope
identifier and monotonic tenant revision. Every successful ordinary page write,
batch and rollback advances it under the same lock. Atomic `ReadPageState` returns
scope/revision plus rows; plans/receipts bind them and refuse replay or scope drift.
The scope is a content namespace identifier, not an authority grant or credential.

The explicit SQL migration is a **new required host schema prerequisite**. It
initializes existing tenants and new-tenant ledger rows. No automatic migration,
existing-schema mutation or consumer adoption is authorized by this source PR.
Before adoption, the host must review/apply the migration and fence writes while
replacing every older writer: mixed old/new plugin versions would bypass the
ledger. A new store instance against the same isolated database must retain the
rollback/replay guard. Canonical block links, absolute reference exclusions and
collision-free temporary paths receive regression coverage in the same correction.

## Independent-review correction: nested HTML resources

The full `afc9dc9` review found unchecked nested/resource HTML attributes.
Preparation now refuses inline nested documents and object/plugin embeddings,
validates form override URLs and the other supported resource attributes, and
refuses unsupported document base/refresh overrides. Regressions exercise page
bodies, selected CMS templates and bundled HTML, including private upload/API
URLs. Public HTTPS iframe sources and validated form overrides remain supported.
This does not add a general active-content sandbox or change the host publication
gates, schema prerequisite, consumer pins or approval boundary.

## Consumer-boundary correction: selected public media index

The unchanged reviewed-source PostgreSQL consumer completed the generic frozen
snapshot → explicit mapping/dryrun → apply → safe rollback path, including
cross-tenant/scope refusals, monotonic versions/revisions and newer-edit refusal.
The optional private six-page bundle then exposed a public `/media` navigation
collision with the upload namespace refusal. The failed optional run and all
completed generic evidence remain preserved outside this public repository.

Parent authorizes a narrow exception for relative hyperlinks to exactly the
selected public CMS route `/media`. Resource sources, upload children, absolute
private URLs, unselected routes and other private namespaces still refuse.
Acceptance covers body/template/static HTML and canonical block hrefs, encoded
and dot-normalized upload refusals, plus the private bundle consumer in a fresh
PostgreSQL schema. New exact-head tests and full independent source review are
required. No hosted write, migration/adoption, source pin or live apply route
is added; the prior locked plan and release gates stay intact.

The early source review tightens this exception to canonical raw `/media` or
relative `media` path spelling. Encoded, dot-segment and trailing-slash aliases
remain refused even when normalization would resolve to the selected route.
The same negative cases cover all HTML surfaces and canonical block hrefs.
96 changes: 96 additions & 0 deletions docs/promotion.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
# Preparing approved content publication

The `promotion` package prepares frozen, content-only snapshots and explicit
production diffs. It has no network client, background synchronization or live
apply endpoint. Review edits stay in their review tenant. An approval must name
the frozen snapshot digest and production diff; later drafts require a new
approval.

Export selected page IDs from an authorized review tenant using
`promotion.Export(ctx, pages, reviewID, sourceSite, ids, immutableBundleDir, now)`.
Archive the returned snapshot and exact bundle bytes together. The snapshot
includes source page ID/version keys, content and SHA-256 file inventory. Strict
`promotion.Decode` refuses unknown/duplicate JSON keys, trailing documents and
oversize input using fixed errors. It never imports tenant settings or authority.

Read the production tenant atomically using `ReadPageState` and inventory its immutable bundle. The returned durable content scope/revision is part of the batch baseline. Review
`promotion.SuggestedMapping` path matches, then supply an explicit mapping for
every selected page to `promotion.DryRun`. Target page IDs are distinct from source
IDs. A zero target ID explicitly creates; moves require `AllowMove`; deletion
requires a separate `DeleteSelection`. Omission preserves existing pages. The plan
contains the complete target page baseline, both bundle digests and content diffs.
Dry run never writes.

`store.PageBatchStore` supplies persistence primitives, not publication authority.
`ApplyPageBatch` atomically compares the entire page baseline and applies content
in one tenant. Ordinary creates, saves and deletes share its per-tenant lock.
Update requires the caller's loaded `Page.Version`. API PUT and DELETE require
`expected_version`; missing preconditions fail and stale versions return 409.
The editor keeps an unsaved draft on conflict and refreshes its version after a
successful save. Existing external API consumers must upgrade before this plugin
is adopted. Every successful page write also advances the durable tenant content
revision; receipts bind that revision and the content scope, preventing receipt
replay even after create/delete history leaves the same surviving rows. `PageStore.Delete` also gains a required expected-version argument.

Archive the content-only batch receipt before completing host publication. Its
before/after rows, explicit mapping and integrity digest support
`RollbackPageBatch`, which refuses any change to the recorded post-state and
restores affected content with increasing versions. The receipt digest detects
corruption; it is neither a signature nor approval. Durable backup storage and
operator authorization belong to the host publication workflow.

`VerifyBundle` re-hashes regular files, verifies template/assets references and
refuses symlinks, missing assets, uploaded tenant media URLs, admin API references
and static files shadowing selected CMS routes. Bundled photos retain relative
references and exact hashes. The operator must stage those verified immutable
bytes in the target bundle. The library does not configure media storage, copy
uploaded objects, fetch third-party embeds or claim rights to external media.

HTML checks cover page bodies, CMS templates and bundled HTML. Inline nested
documents (`srcdoc`), object/plugin embeddings (`object`, `embed`), reference base
overrides and meta refresh are unsupported and refuse validation. Public HTTPS
iframe sources remain supported; form action/override URLs, image source sets
and other resource attributes use the same private-path and asset checks.
This is reference validation, not a JavaScript sandbox. The host must review the
exact approved bundle's scripts and runtime behavior separately.

The public CMS index route `/media` may be a relative hyperlink only when that
exact route is in the selected snapshot pages. The exception covers navigation,
including canonical block links; it does not permit media resource URLs, upload
children, unselected routes, absolute private URLs or other private namespaces.
Only canonical `/media` or relative `media` path spelling qualifies; encoded,
dot-segment and trailing-slash aliases remain refused.

The SQL prerequisite `store/postgres/migrations/0001_page_content_revision.up.sql`
must be reviewed and applied by the host before adoption. It initializes tenant
content scopes/revisions and new-tenant initialization. This library never applies
it automatically. Fence writes while replacing all older writers; mixed versions
bypass the ledger. Preserve ledger history across restart/backup, and never drop
or reset it to force a conflicted rollback.

Live application remains blocked on host integration: current superadmin
publication authority, a named approval digest, an exclusive publication/write
fence, staged immutable assets, durable content/bundle backups, approved production
bootstrap source and actual restart/redeploy proof. A database transaction cannot
atomically swap a filesystem bundle or cloud bootstrap setting. Preserve the
existing domains, credentials, roles, memberships and repository mapping. If the
host cannot provide the fence and restart-safe bundle, use a separately reviewed
maintenance procedure and do not enable partial publication.

## Verification

`go test ./store ./promotion ./internal ./adminui ./store/postgres` runs focused
unit/API checks. Set `CMS_TEST_DATABASE_URL` to an isolated authorized test server
to run the real Postgres contract; each test creates and cleans only a fresh
`promotion_20261008_*` schema. CI provisions a disposable Postgres service and
checks the exact PR head before its existing vet/race checks.

The optional actual-editor test uses already installed tools. Set
`CMS_TEST_PLAYWRIGHT_PATH` to the local Playwright package and
`CMS_TEST_BROWSER_EXECUTABLE` to the browser binary; `CMS_TEST_SCREENSHOT` is an
optional screenshot destination. `TestPostgresEditorVersionBrowser` launches
only a temporary browser context and ephemeral loopback listener. Its authority
and promotion helper exist only in the test binary. It demonstrates repeated
save version refresh, stale save/delete conflicts after a batch, retained draft,
wrong-tenant denial and save/reload through the real editor/API/Postgres boundary.
It does not prove hosted authentication or host bundle activation.
Loading
Loading