fix(publish): scope entry-route runtime assets to their own template - #320
Merged
Conversation
Every entry route on a site served one arbitrary page's scripts. `getLatestPublishedSiteSnapshot` is `order by data_rows.created_at asc limit 1` — the first published page ever created. It exists to carry `site_json` for routes that are not themselves pages, and its `runtime_assets_json` rode along by accident. Entry routes took it verbatim, so `assetScopeAppliesToPage` was never evaluated on that path at all: a script scoped to two pages shipped on all ten entry routes, and had the oldest page carried no scripts the same bug would have shipped a scoped script nowhere. The 404 route inherited the same manifest, and so did the publish-time bake, which is what most requests actually serve. That getter no longer carries runtime assets, so a miss now degrades to "no scripts" rather than "someone else's scripts". `entryTemplateSnapshot.ts` resolves the manifest from the page that actually renders — the innermost template in the chain — and treats its absence as authoritative too, so a manifest arriving from anywhere else cannot survive onto a route it was never scoped to. Also fixed, found on the way: `composeTemplateChain` built its result field by field and dropped `Page.template`, and `assetScopeAppliesToPage` gates its `templates` branch on `Boolean(page.template)`. A stylesheet scoped to an entry template therefore never applied to that template's own routes — the one place it was meant to. And `applyStatus` called `updateSelectedEntry` for whatever row it published, active or not, silently retargeting the workspace. That discarded whatever an author had open and unsaved, and left a tool loop of `set_document_fields → set_document_status` writing one document behind itself, so every field write after the first was refused. It now uses `applyEntryUpdate`, which selects only a row that was already active — matching the guard the non-agent paths have always had. The field tools' descriptions state the active-document precondition they have always enforced. Test: an entry route takes its template's manifest, ignores a stale one, keeps the site document it resolved against, and falls back cleanly with no template; a composed template keeps its config so a template-scoped asset matches; publishing another document leaves the active one alone and a following write to it still lands. Verified live: pack 020's page-scoped script now appears on `/` and `/circuit` only, where it previously appeared on those plus all ten entry routes.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Every entry route served one arbitrary page's scripts
A runtime script scoped to two pages:
All ten entry routes carried it, and all served the same bundle id —
/circuit's.Why
getLatestPublishedSiteSnapshot(repositories/publish.ts) isorder by data_rows.created_at asc limit 1— the first published page row ever created. It exists to supplysite_jsonto routes that are not themselves pages, and itsruntime_assets_jsonrode along by accident; the doc comment even labels it "first published page snapshot (for 404s etc.)".Entry routes are rendered per request from a template plus a data row, so they have no manifest of their own and took that one verbatim at
publicRouter.ts:174.assetScopeAppliesToPageis a correctpageIds.includes(page.id)test, but nothing on the entry-route path ever calls it —collectRuntimeScriptsruns only insidepublishSite.ts's loop oversite.pages. The scope decision made for the oldest page was simply replayed.Over-inclusion is what surfaced. The same bug under-includes just as easily: had the oldest page carried no scripts, a scoped script would have reached nothing. The 404 route inherited the same manifest, and so did the publish-time bake (
bakeDataRows.ts,publishRow.ts) — which is what most requests actually serve.The fix
getLatestPublishedSiteSnapshotno longer carries runtime assets at all, so the failure mode is "no scripts", never "someone else's".server/publish/entryTemplateSnapshot.tsresolves the manifest from the page that actually renders — the innermost template in the chain — and treats its absence as authoritative too, so a manifest arriving on the snapshot from anywhere else cannot survive onto a route it was never scoped to. Applied at all four sites: the request path, the 404, and both bake paths.Two more, found on the way
Template-scoped CSS never reached its own routes.
composeTemplateChainbuilds its result field by field and droppedPage.template;assetScopeAppliesToPagegates itstemplatesbranch onBoolean(page.template). So a stylesheet scoped to an entry template applied everywhere except the routes it targeted.Publishing a document stole the editor's active document.
applyStatuscalledupdateSelectedEntryfor whatever row it published, active or not. Two consequences: it yanked whatever a human had open, discarding their unsaved draft (useContentEntryDraftre-applies on aselectedEntryid change); and it left a tool loop ofset_document_fields → set_document_statuswriting one document behind itself, so every field write after the first was refused. The non-agent paths already guard this (useContentWorkspace.ts:459,:471) — this brings the agent path in line via a newapplyEntryUpdate, rather than leaving the two asymmetric. The field tools' descriptions now state the active-document precondition they have always enforced.Verification
Unit — an entry route takes its template's manifest, ignores a stale one, keeps the site document it resolved against, and falls back cleanly when a table has no template; a composed template keeps its config so a template-scoped asset matches and an unrelated one still does not; publishing another document leaves the active one alone and a following write to it still lands.
That last test is written with one
act()per tool call on purpose — batched into a singleact()it passes against the unfixed code, because React never commits and the bridge's workspace ref stays stale. I checked it fails without the fix and passes with it.Live — republished pack 020, which has a genuine page-scoped runtime. The script now appears on
/and/circuitonly; the harness audit reportsroutes with a runtime script: /, /circuit, down from twelve, with 0 audit problems across all 19 routes.Suite: 6490 pass / 0 fail. Lint and
tsc --noEmitclean.Related
Stacks alongside #318 (author bindings publishing the account's email). Independent branches, no overlapping files.
🤖 Generated with Claude Code