feat(plugins): host-managed robots.txt and plugin-claimed root text files - #508
Open
Mariomarquezt wants to merge 2 commits into
Open
feat(plugins): host-managed robots.txt and plugin-claimed root text files#508Mariomarquezt wants to merge 2 commits into
Mariomarquezt wants to merge 2 commits into
Conversation
…tcher `server/router.ts` owned `serveSiteCss` along with its `(bundle, hash)` memo, in-flight de-duplication, and the published-snapshot rebuild walk — about 130 lines of publishing logic in a module whose one reason is to dispatch requests. That also left the dispatcher three lines under the 700-line ceiling `module-size-budgets.test.ts` enforces, so any new route had to displace something first. The block moves verbatim to `server/publish/siteCssServer.ts`, next to the `siteCssBundle.ts` that builds what it serves; `tryServeSiteCssNamespace` now just forwards the path. No behaviour change — same disk-first order, same memo semantics, same responses.
…iles Two SEO standards authorize by file *location*, and plugin routes mount under `/admin/api/cms/plugins/<id>/runtime/*`: the sitemaps.org protocol scopes a sitemap to its own directory and below, and indexnow.org scopes a key file the same way. A plugin therefore had no way to make either feature take effect. Closes CoreBunch#425 (Option B). `/robots.txt` is now host-managed. The host serves it whether or not a plugin contributes, seeding the chain with `User-agent: *` / `Allow: /` — the same instruction to a crawler that its previous 404 carried (RFC 9309 §2.3.1.3), so no crawler behaviour changes on a site with no plugins. Plugins contribute through the new `site.robots` filter, and they contribute *directives*, not text: the host owns the serialization, so a value carrying whitespace, a `#`, or a CR/LF cannot forge an extra directive line. Groups are validated as a unit, sitemap URLs one by one, sitemaps are de-duplicated, and the host default group is re-inserted when the filtered document has none left. The new `site.rootFiles` filter claims one root `.txt` path per file, which covers the IndexNow key. Claimable paths are an allowlist — one root segment, `.txt`, leading alphanumeric — so nested paths, dot segments, percent escapes, and every other extension are rejected, and `/robots.txt` stays reserved for the host. Bodies are capped and may not carry C0 control characters other than tab and newline. A path claimed by two plugins is served by neither, because resolving it to one winner would silently authorize the wrong submitter; the refusal is logged with the candidate plugin ids, which is what the new `hookBus.pluginsFor` exists for. Both filters ride the existing `cms.hooks` permission rather than a new one: `publish.html` already lets a `cms.hooks` plugin rewrite every published page, which is strictly more power than serving one inert root text file. Both responses go out as `text/plain` with `nosniff`, `default-src 'none'`, and `no-store`, and neither is baked into the published slot — both depend on which plugins are active now, not on the published snapshot. Both handlers sit after every host-owned namespace and before `tryServePublicRoute`. They cannot shadow content: `pageSlugError` rejects any page slug containing `.` and a data-row route needs at least `/<table>/<slug>`, so no published URL is ever a root `.txt` path.
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.
Summary
Closes #425 with Option B.
Two SEO standards authorize by file location, and plugin routes mount under
/admin/api/cms/plugins/<id>/runtime/*. The sitemaps.org protocol scopes a sitemap to its ownpath and below, and indexnow.org scopes a key file the same way — so a plugin can register both
endpoints and have neither take effect. There was no root-level surface at all, and no way to add
a
Sitemap:line to arobots.txtthat did not exist./robots.txtis now host-managed. The host serves it whether or not a plugin contributes,seeding the document with
User-agent: */Allow: /— the same instruction to a crawler thatthe previous 404 already carried (RFC 9309 §2.3.1.3), so no crawler behaviour changes on a site
with no plugins. Plugins contribute directives, not text, through a new
site.robotsfilter:The host owns serialization, so a value carrying whitespace, a
#, or a CR/LF cannot forge anextra directive line. Handlers chain in registration order; groups are validated as a unit and
sitemap URLs one by one (
filterArray, the same tolerance as a corrupt font entry); sitemaps arede-duplicated; the host default group is re-inserted if the filtered document has none left.
A new
site.rootFilesfilter claims one root.txtpath per file, which covers the IndexNowkey:
Claimable paths are an allowlist — one root segment,
.txt, leading alphanumeric — so nestedpaths, dot segments, percent escapes and every other extension are rejected, and
/robots.txtstays reserved for the host. Bodies are capped at 4 KiB and may not carry C0 control characters
other than tab and newline. A path claimed by two plugins is served by neither: resolving it
to one winner would silently authorize the wrong IndexNow submitter. The refusal is logged with
the candidate plugin ids, which is what the new
hookBus.pluginsForexists for.Design notes
cms.hooks.publish.htmlalready lets acms.hooksplugin rewrite every published page, which is strictly more power than serving one inert root
text file — a separate permission would add a consent line without adding safety, and would
have required editing the locked
EXPECTED_TARGET_PERMISSIONStable.media.url.transform. TypeBox schemas insrc/core/plugin-sdk/siteRootSchemas.tsare the source of truth; every list bound is enforcedby a host-side
sliceas well asmaxItems, becauseapplyFilteronly checks the value'sruntime type category.
and lives in plugin settings, so a static manifest path cannot carry it. Riding
hooks.filteralso means
hookBus.unregisterPluginalready handles teardown on disable, uninstall and crashrecovery — no new call sites.
tryServePublicRoute. They cannot shadow content —pageSlugErrorrejects any page slugcontaining
., and a data-row route needs at least/<table>/<slug>, so no published URL isever a root
.txtpath. An unclaimed.txtpath returns null and keeps falling through to thesite's 404.
.htmlfiles named from pageroutes and rewritten wholesale per publish; both of these documents depend on which plugins are
active now, so a baked copy would keep serving a disabled plugin's sitemap line or key file.
Responses go out
text/plainwithnosniff,default-src 'none'andno-store.On the preparatory commit, and PR scope
server/router.tswas three lines under the 700-line ceilingmodule-size-budgets.test.tsenforces, so any new route had to displace something first. The first commit moves
serveSiteCss— with its
(bundle, hash)memo, in-flight de-duplication and published-snapshot rebuild walk —into
server/publish/siteCssServer.ts, next to thesiteCssBundle.tsthat builds what itserves. Verbatim move, no behaviour change, separate commit so it reviews independently.
Flagging this against
AGENTS.md's "keep PR scope coherent": the extraction is not opportunisticcleanup, it is forced by your own size gate, and
AGENTS.mdalso says not to justify a workaroundwith "to keep this PR small". If you would rather have a
GRANDFATHEREDentry than theextraction, that swap is trivial and I will make it.
Security
Every case below has a test in
src/__tests__/server/siteRoot.test.ts: robots.txt directiveinjection via CR/LF;
#comment smuggling; response-header injection (plugins never supplyheaders or raw text); path traversal, raw and percent-encoded (the pathname is matched raw and
never decoded, and a claimable path cannot contain
%); a plugin claiming/index.html,/favicon.svg,/sitemap.xmlor a host-owned namespace; a plugin hijacking/robots.txt; oneplugin stealing another's path; unbounded content size (4 KiB per body, 20 claims, 20 groups, 50
sitemaps, 100 paths per group); control-character and terminal-escape payloads; content-type
confusion (forced
text/plain,nosniff); ajavascript:/file:sitemap URL; a buggy orthrowing plugin emptying robots.txt; and method confusion.
Known trade-offs
/robots.txtreturns 200 where it returned 404 — the one intentional behaviour change,argued above as a no-op for conforming crawlers. If you disagree, the fix is one line:
return nullfromserveRobotsTxtwhen!hasFiltersFor('site.robots').Disallowdrops the wholeUser-agentgroup, not just that line. Matches
filterArraygranularity elsewhere.applyFilterchains handlers opaquely, sothe duplicate-claim log names all plugins registered on
site.rootFilesas candidates ratherthan the two actual claimants. This is the one place Option A would genuinely be better.
.txtGET on a site with asite.rootFilesplugin costs oneworker round-trip. A revision-keyed memo would go stale when a plugin's settings change the
key it serves; reasoning is in the module header.
Docs updated in the same change: the filter table and a new "Site-root text files" section in
docs/features/plugin-system.md, the route table and ordering notes indocs/server.md, and themodule tables in
docs/features/publisher.md.Verification
bun run build—tsc -b && vite build,✓ built in 14.05s, no type errorsbun test—6867 pass / 0 fail across 739 filesbun run lint— clean, exit 0Baseline on
3a9543edwas6818 pass / 1 skip / 0 fail. The +48 reconciles as +31 new tests here(30 in
siteRoot.test.ts, 1 inpluginHookBus.test.ts) and +17 frombundle-size-budgets.test.ts,which logs
dist/assets/ missing — bundle gates skippedon a clean tree and runs its 18 tests oncebun run buildhas produceddist/.One caveat worth stating: a single baseline run before this change failed with a QuickJS
Aborted(Assertion failed: list_empty(&rt->gc_obj_list))in the plugin-VM tests. Subsequent runswere clean, so that flake looks pre-existing and unrelated, but you may already know about it.
Checklist