Production hardening: security P0s, PHP 8.3 compat, SEO/GEO, data integrity - #1
Merged
Merged
Conversation
- Upgrade laravel/framework ^11.31 -> ^12.61.1 and re-resolve deps; clears all 19 composer-audit advisories (incl. 2 high: Laravel CRLF email-rule injection, symfony/mime SMTP injection). composer audit now reports no advisories. Test suite green (125 passed). - Pin config.platform.php to 8.2.0 so vendor/ resolves for the production floor (target hosting is PHP 8.3) instead of dev's 8.5. - Fix JSON-LD stored-XSS: encode structured data with JSON_HEX_TAG|HEX_AMP|HEX_APOS|HEX_QUOT (drop UNESCAPED_SLASHES) so a </script> in any SEO field can no longer break out of ld+json. - Prevent module public-asset webshell: ModulePublicAssetsPublisher now copies only an allowlist of static extensions (refusing .php/.phar/ dotfiles) and drops a hardened .htaccess denying script handlers under public/modules. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Closes the authenticated-RCE and silent-broken-deploy P0s in the updater: - Manual package upload now fails closed: requires a configured signing public key AND a valid detached Ed25519 signature over the ZIP bytes (same gate the cloud path uses), so an admin/hijacked session without the private key can no longer ship arbitrary PHP. Extract the verifier into a shared PackageSignatureVerifier used by both paths. - Make the cloud checksum mandatory (was skipped when sha256 was empty). - Downgrade/replay protection: refuse to install a version older than the installed one (upload-time and in preflight, all sources). - Preflight requires a public key for manual packages too (was cloud-only). - Post-apply health check now boots the new code out-of-band via an HTTP request (5xx => fail => auto-rollback), instead of only pinging the DB; unreachable loopback is treated as unverifiable, not a failure. - Zip-bomb guard: cap archive entry count and uncompressed size on extract. - Upload form accepts a .sig file or pasted base64 signature. Tests: signed-upload success + unsigned/tampered/no-key rejection; full suite green (127 passed). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Per-translation custom_head_html was emitted raw into <head> and the custom_code_embed / html_embed_restricted blocks re-injected author markup — both with no role check, so any pages:write editor could store XSS. Now gated to the same advanced roles as Page.custom_code: - PageTranslationNormalizer / PostTranslationNormalizer abort(403) when a non-advanced actor submits a non-empty custom_head_html or a restricted code-embed block. Threaded an allow_custom_code flag (computed from the actor via a shared LocalizedContentHelpers::actorMayUseCustomCode) from PageContentService / PostContentService; duplicate copies trusted content so it bypasses the gate. - Stage-preview passes the previewing user's capability so advanced editors keep live preview of custom-code blocks. Tests: editor blocked on custom_head_html (service) and on a custom_code_embed block (API e2e); advanced role still allowed. Full suite green (130 passed). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Installing/updating/activating a module executes its PHP (providers, migrations, routes), so these must not be reachable by a merely settings:write-delegated account. - ModuleController: code-introducing actions (upload, installLocal, installBundled, activate, update) now require superadmin via a new ensureCanInstall gate; index/docs/deactivate/uninstall keep the settings:write-or-superadmin gate. - recoverExistingInstall now also requires the on-disk manifest version to match the requested module, so a stale/planted directory of another version is not silently adopted (id-match guard was already present). Test: a settings:write (non-superadmin) user can view modules but is forbidden from installing. Full suite green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Asset uploads (web + admin API) now validated against a configurable extension allowlist (cms.uploads.allowed_extensions) via the mimes rule, which checks file content — so .php/.phtml/.html and .svg (script- carrying XSS vector) are rejected instead of stored under the public disk. Default excludes executable/markup types. - Register named rate limiters (content-api, admin-api, llm) and apply throttle middleware to the content/v1 and admin/v1 route groups and the paid LLM generate endpoints, closing the unbounded-DoS / LLM cost-abuse surface. Limits are config-driven. Test: disallowed upload types (.svg/.php/.html) rejected with 422. Full suite green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- SystemCheckService: intl/bcmath/exif marked optional (CMS doesn't use them) so install isn't blocked on shared hosts lacking them. - SetupFinalizationService: guard putenv() with function_exists (often disabled on shared hosting); storage:link now falls back to copying the directory when symlink() is unavailable instead of silently no-opping. - FilesystemUpdateDriver::apply: set_time_limit(0)+ignore_user_abort so a vendor copy + migrate in one request isn't killed by max_execution_time or a dropped connection mid-update. - CoreBackupService: guard proc_open (pg_dump/mysqldump); degrade to a file-only backup with a visible warning instead of hard-failing the update on hosts without shell-out. - CI: matrix over PHP 8.2/8.3/8.4 (was 8.4 only) + composer audit gate. - docs: cron marked required (scheduled publish silently fails without it). Full suite green (132 passed). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- head-meta: emit OpenGraph (og:title/description/type/url/site_name/ locale + locale:alternate/image) and Twitter Card tags, derived from the existing SEO view model with graceful fallbacks. Previously none were output — broke social unfurling and several AI snippet extractors. - CmsLayoutViewModelFactory: canonical is now host-validated — an absolute off-site canonical (from a stored override) is rewritten to the app host keeping path+query, so it can't deindex toward or open-canonical to another domain. - buildHreflangs: add an x-default alternate pointing at the default locale. Test: SeoHeadTest asserts canonical/OG/Twitter/hreflang+x-default output. Full suite green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- SeoController sitemap + llms.txt now filter via the published() scope (adds the published_at <= now() embargo check the inline filter missed) and skip translations marked robots noindex, so de-indexed/scheduled content is no longer advertised to search and AI crawlers. - StructuredDataFactory: add graph() (assemble a JSON-LD @graph, stripping per-node @context), faqFromBlocks() (FAQPage from faq layout blocks) and a WebSite SearchAction; enrich article() with author/publisher/image/ mainEntityOfPage. The Organization/WebSite/FAQPage/BreadcrumbList methods were dead code — now emitted. - Public page/post resolvers assemble an @graph (Organization + WebSite + WebPage/BlogPosting + FAQPage) unless an explicit per-translation structured_data override is set. Tests: sitemap/llms.txt exclude noindex+embargoed; head emits @graph with Organization/WebSite; faq block emits FAQPage. Full suite green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Admin API (page/post/category) validate translations.*.locale against Rule::in(supported_locales) so unsupported locales can't create orphaned translation rows / broken hreflang. - Translation persisters prune locales no longer in the submitted set, so a removed/cleared translation stops resolving publicly and frees its slug. - LocaleResolver::effectiveDefault() returns the configured default only if it is actually supported, else the first supported locale; normalize() uses it. HomeRedirectController redirects the site root there, so a default_locale not in supported_locales no longer 301s '/' into a 404. Tests: unsupported-locale 422; removed locale pruned on update; effective default falls back + '/' redirects to a routable locale. Full suite green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Dynamic /robots.txt now emits explicit per-agent blocks for AI/generative crawlers (GPTBot, ClaudeBot, PerplexityBot, Google-Extended, CCBot, …), Allow by default (GEO goal) and Disallow when SEO_ALLOW_AI_BOTS=false. Configurable via config/seo.php ai_bots. - Remove the static html_public/robots.txt and public/robots.txt (and drop robots.txt from updates managed_public_paths): the static file was served ahead of the dynamic route on shared hosting, silently dropping the Sitemap directive and AI-bot policy. The Laravel front controller now always serves the dynamic, sitemap-aware robots.txt. Test: /robots.txt asserts Sitemap + GPTBot/ClaudeBot blocks. Full suite green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
post_listing previously emitted an empty <div data-category data-limit> with no hydration, so the block was permanently blank for users and crawlers. It now server-renders the published posts: queries PostTranslation by locale via the post published() scope (excludes drafts + future-dated), optionally filtered by category_slug, ordered newest-first and limited, emitting a <ul> of titled links + excerpts. Note: rendered at page-save time (cached rendered_html), so the list reflects posts as of the last page save — request-time freshness is a follow-up. Test asserts published posts render and drafts/embargoed do not. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…title video_embed dropped the author URL into an iframe src with only HTML escaping. It now requires https + a host on cms.custom_code.safe_embed_ domains, so an arbitrary/phishing/javascript: origin can't be embedded; off-allowlist URLs render nothing. Also add a title attribute to the iframe (WCAG 4.1.2 name/role/value). Test: youtube embed renders with title; off-host, http and javascript: are rejected. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- ContentEntityCleanupObserver (Page/Post/Category deleting): purges the rows keyed by (entity_type, entity_id) that have no FK — seo_overrides, content_revisions, preview_tokens, publish_schedules, slug_histories — and deletes the auto-301 redirect rules pointing at the entity's URLs, so a deleted entity no longer 301s to a 404 or hijacks a reused slug. - TranslationSlugObserver: on a slug change, drop the inverse redirect rule (new -> old) so renaming a slug back can't create an infinite 301 loop, and rewrite chained rules (X -> old) to point at the new URL directly. Tests: deleting a page purges its orphan rows + stale redirect; reverting a slug leaves no loop. Full suite green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Add a composite (status, published_at) index on pages and posts so the
hot published() listing predicate is sargable (only two independent
single-column indexes existed).
- .env.example LOG_LEVEL debug -> warning: debug logging can capture
request payloads/secrets on shared hosting.
- Add branded errors/{403,404,419,429,500,503} views (noindex) so failures
no longer expose the framework default page / stack traces.
Test: unknown URL renders the branded 404; migration runs in the suite.
Full suite green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Addresses WCAG findings on the public site: - Restore focus visibility: the search input previously set outline:none (the only indicator). Add a global :focus-visible outline for links, buttons, inputs, selects, textareas, summary and tabindex elements, plus a focus-visible style on the search field. - Add a skip-to-content link as the first focusable element, targeting a new id="main" (tabindex=-1) on the <main> landmark, so keyboard/SR users can bypass the header/nav. - Give the reusable search input an aria-label (placeholder is not an accessible name). Test: public page exposes the skip link, #main landmark and focus-visible styles. Full suite green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The image and gallery blocks rendered <img loading="lazy"> with no intrinsic dimensions, causing layout shift. When the block carries width/height (from the media picker) they are now emitted, plus decoding="async", so the browser reserves space (better CLS/LCP). Test: image block emits width/height + decoding when provided, and no bogus attributes when absent. Full suite green (146). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Folds in the pre-existing landing-page/chrome work this hardening branch was built on top of (entangled with the hardening commits at the file level, so kept in one PR): - Site header/nav "chrome builder": configurable header variant + menu position + logo, nested nav (1 level), public chrome rendering (chrome-header/chrome-nav/public-runtime partials, ResolvedChrome ViewModelFactory, SiteChromeNormalizerService). Nav hrefs are scheme-allowlisted (http/https/mailto/tel) and output is Blade-escaped. - Carousel block: schema (BlockSchemaValidator) + public render (cms-carousel) with overlay/CTA/autoplay/arrows/dots; empty slides skipped at render. - html_public managed public-root sync (ManagedPublicRootSyncService): syncs the managed allowlist from the release html_public into the active public root, with snapshot/restore for update rollback. - EnvWriterService: quote/escape .env values (prevents .env injection). - html_public/index.php: move testoCmsNormalizePath above bootstrap so it is defined before use. - Tests: CarouselBlockTest, PageStagePreviewTest, ManagedPublicRoot SyncServiceTest + updated chrome/env tests. - gitignore /html_public/modules (runtime-published module assets). Known review findings to address as follow-up (not blocking the rest): - P0: carousel JS normalize-on-edit drops empty-src slides, so "add slide" no-ops and editing a slide can overwrite another (page-form.js / page-fullscreen.js normalizeCarouselData). - P2: logo src has no scheme validation; chrome-builder setPath lost its array guard; decorateNavLink parse_url(null) deprecation on PHP 8.3. Full suite green (146 passed). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The Laravel 12 upgrade bumped laravel/pint 1.27 -> 1.29, which enables the fully_qualified_strict_types rule. Apply it repo-wide so `composer lint` (pint --test, the CI gate) passes. Formatting only. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Carousel (page-form.js, shared by the fullscreen builder via the bridge): normalizeCarouselSlides filtered out empty-src slides on every edit and render, so "add slide" silently no-op'd (the new card was dropped before it rendered) and editing slide N could overwrite a different slide (re-indexing after the filter). Now it keeps every slide while editing; empty slides are skipped only for the visual preview (renderableCarouselSlides) and dropped server-side on save (PageTranslationNormalizer / renderCarousel already do this). Verified by adversarial review across every call site in both editors. Also restrict the chrome logo src to same-origin/http(s)/data:image (SiteChromeNormalizerService) so other schemes are dropped. Tests: chrome_logo_src_drops_dangerous_schemes; CarouselBlockTest green. Full suite green (147 passed). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… scan
Site search ran a leading-wildcard LIKE ('%term%') against the LONGTEXT
body columns (content_plain / rendered_html) — non-sargable full-table
scans — while the FULLTEXT (MySQL) and GIN to_tsvector (Postgres) indexes
the schema builds for exactly this went unused.
applyTextSearch is now driver-aware: substring LIKE on the short columns
(title/excerpt/meta) for partial matches, plus orWhereFullText (MySQL/
MariaDB) or to_tsvector @@ plainto_tsquery (Postgres) on the body columns
so the body stays searchable via the index. sqlite/other drivers search
only the short columns (no LONGTEXT scan).
Test: SiteSearchTest — post matched by title, page by meta_description,
no match for an absent term. Full suite green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… rest - RunPublishSchedulerFallbackMiddleware used a non-atomic check-then-act on the cms:scheduler:last-run cache key, so concurrent requests could all pass the 30s window and run the publish scheduler (and flush caches) more than once. Wrap runDue() in an atomic Cache::lock with a re-check inside. - CoreUpdateSettingsService now encrypts the deploy-hook bearer token at rest (Crypt) instead of persisting it in plaintext in theme_settings, and decrypts it in resolved(); legacy plaintext values are tolerated. Test: deploy_hook_token_is_encrypted_at_rest (stored value != plaintext, resolved() returns plaintext). Full suite green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The full ~42KB theme stylesheet (structural base + per-theme tokens) was inlined into every public page and baked into the full-page cache. Now the large theme-independent base sheet is served from /cms/theme-base.css with Cache-Control: immutable + a ?v=<content-hash> cache-buster, and only the small per-theme :root token block is inlined. This shrinks every page's HTML (and the cached entry) by ~42KB and lets the browser cache the base sheet across pages — better LCP/transfer. ThemeCssRenderer exposes baseCss()/baseCssHash()/dynamicCss(); the theme view model adds theme_css_dynamic + theme_base_css_url (with a fallback to the old inline theme_css if those are absent). Test: base-css endpoint immutable + contains structural rules; public page links it externally and inlines only tokens (no base rules). Suite green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Follow-up to externalizing the theme base CSS — the a11y focus-visible and skip-link rules now live in /cms/theme-base.css rather than inline, so the accessibility test checks them there. Full suite green (153). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Install larastan and run phpstan level 5 over app/. The 192 pre-existing findings (mostly Eloquent dynamic-property access on un-annotated models) are captured in phpstan-baseline.neon so existing code is grandfathered and only NEW violations fail. Wired in as `composer analyse` and a CI step between lint and test. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
New "stats" landing block — a responsive grid of value + label cards (e.g. "500+ / Clients"), the kind of metric strip landing pages use. Wired through every layer, mirroring the gallery line-format block: - config/cms.php allowed_types; BlockLeafRendererService::renderStats (escaped, skips empty items); PageTranslationNormalizer hasMeaningfulNode so it survives save. - Both editors (page-form.js + page-fullscreen.js): type label, default block, summary, live preview, and a "value | label" textarea inspector, with from/toStatsLines exposed on the builder bridge. - .cms-stats grid styling in the base stylesheet. Tests: server render (value/label cards, empty items dropped) and an end-to-end create-page path (block persists + renders in rendered_html). Full suite green (155); phpstan clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…locks
Four new landing blocks wired through every layer (config allowed_types,
BlockLeafRendererService render methods, PageTranslationNormalizer
hasMeaningfulNode, both JS editors via the line-format/fields pattern, and
base CSS):
- hero: heading + subheading + CTA + optional background image + align
(fields block; CTA url goes through safeLinkUrl).
- features: grid of icon/title/text cards ("icon | title | text" lines).
- testimonial: quote/author/role cards ("quote | author | role" lines).
- pricing: tier cards with name/price/period/feature-list/CTA
("name | price | period | feat; feat | label | url" lines).
All renderers escape output, skip empty items, and return '' when empty.
Tests: LandingBlocksTest renders each block, skips empties, neutralises a
javascript: hero CTA. Full suite green (160); phpstan clean.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
sitemapLocale and llmsTxt streamed a full DB cursor scan on every crawler hit, ignoring the declared seo.sitemap.cache_ttl. They now build the body (output-buffered) inside Cache::remember keyed by locale with that TTL and return a normal cached response — so repeat crawls don't re-scan the DB. Tests: sitemap/llms responses populate their cache keys; leak/embargo assertions retained (switched from streamedContent to getContent). Full suite green (161); phpstan clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add an opt-in (CMS_CONSENT_ENABLED) cookie-consent banner on public pages:
accept/decline, remembered in localStorage, with an optional policy link
(CMS_CONSENT_POLICY_URL). The visitor's choice is exposed as
window.testoCmsConsent ('accept'|'decline') and a testocms:consent event,
so analytics/embed scripts can gate themselves behind consent. Accessible
(role=region, labelled) and styled in the base stylesheet.
Tests: banner present when enabled (with policy link), absent when off.
Full suite green (163).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Edit-mode autosave was a data-loss trap: the editor posts back to the same URL, so a local draft snapshot survived a successful save and, on the next load, recoverAutosaveIfNeeded() offered to "restore" it — overwriting the just-saved page with pre-save content. Fix: the page/post update redirect now flashes content_saved; the editor boot surfaces it as justSaved; on a just-saved load the client drops its now-stale snapshot (and seeds lastSavedFingerprint from the server payload so autosave resumes cleanly on the next real edit) instead of prompting to restore. A failed save flashes nothing, so the draft is preserved. Wired symmetrically for pages and posts. Tests: AutosaveSignalTest asserts a successful update flashes content_saved and the edit boot renders justSaved true (and false on a plain load) for both pages and posts. Adversarially reviewed the JS scope/side-effects. Full suite green (165); phpstan clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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
Production-hardening pass on the SEO/GEO CMS, driven by a multi-agent security/readiness review. Each item is a focused commit with tests; full suite green (146 passing, was 124) and
composer auditclean.Security — all P0 blockers closed
laravel/framework11 → 12 and re-resolve; clears all 19composer auditadvisories (2 high).config.platform.phppinned to8.2.0sovendor/resolves for the production floor, not the dev runtime.PackageSignatureVerifier, same gate as the cloud path). Mandatory cloud sha256, downgrade/replay protection, zip-bomb guard.</script>breakout fixed (JSON_HEX_*); per-translationcustom_head_htmland thecustom_code_embed/html_embed_restrictedblocks are now role-gated to advanced roles.public/publishing uses a static-extension allowlist + hardened.htaccess(no more.phpwebshell)..php/.svg); rate limiters on content/admin/LLM APIs;video_embedrestricted to https + an allowlist of hosts.PHP 8.3 / shared hosting
intl/bcmath/exifmade optional in the installer;putenv/symlink/proc_openguarded with graceful fallbacks; synchronous update givenset_time_limit(0)/ignore_user_abort; CI matrix now spans 8.2/8.3/8.4 + acomposer auditgate; cron documented as required.SEO / GEO
x-defaulthreflang. The previously-deadStructuredDataFactorymethods are now emitted as a JSON-LD@graph(Organization, WebSite + SearchAction, WebPage/BlogPosting with author/publisher/image, FAQPage fromfaqblocks).sitemap/llms.txtno longer leak noindex or future-dated (embargoed) content. AI-crawler controls inrobots.txt(configurable allow/deny for GPTBot/ClaudeBot/PerplexityBot/…); removed the staticrobots.txtthat shadowed the dynamic, sitemap-aware route.Correctness / i18n / data integrity
localeagainst supported locales; translation persisters prune removed locales;LocaleResolver::effectiveDefault()prevents the site root redirecting into a 404.seo_overrides/content_revisions/preview_tokens/publish_schedules/slug_historiesand stale auto-301 redirects; slug rename→revert no longer creates a redirect loop.Builder / performance / a11y / ops
post_listingblock server-renders published posts (was a permanently-empty div); image/gallery blocks emitwidth/height(CLS).(status, published_at)index;LOG_LEVELdefault →warning; brandederrors/{403,404,419,429,500,503}pages.:focus-visibleindicators, skip-to-content link +#mainlandmark, search inputaria-label.Verification
composer lint(pint clean on all changed files)composer test(146 passing, 931 assertions)npm run build(no JS changes in this PR)Deployment Notes
(status, published_at)index topages/posts(non-destructive, reversible).php artisan optimize:clearafter deploy (config/route changes).seo.ai_bots,cms.uploads.*,cms.admin_api.rate_limit_per_minute,updates.health_check_url/health_check_timeout/max_uncompressed_mb/max_archive_entries. Manual core-update uploads now requireCMS_UPDATE_PUBLIC_KEY+ a release signature — set the key before using the updater.LOG_LEVELdefault is nowwarning.LIKE-on-LONGTEXT;deploy_hook_tokenat-rest encryption; image pipeline (resize/WebP/srcset); GDPR/consent.Update: landing-builder WIP folded in + review
This branch was built on top of pre-existing landing-builder WIP that is entangled at the file level with the hardening commits (shared
layout.blade.php, block renderer, configs). Per discussion, the WIP is included here rather than split into a fragile second PR. Added commits:Landing builder WIP— site chrome/nav builder (configurable header variant/menu-position/logo, 1-level nested nav, public chrome partials), carousel block (schema +cms-carouselrender),ManagedPublicRootSyncService(html_public managed-root sync + snapshot/restore for update rollback),EnvWriterServiceenv-value quoting (prevents.envinjection), and anindex.phpordering fix. Tests: CarouselBlockTest, PageStagePreviewTest, ManagedPublicRootSyncServiceTest.style: satisfy Pint 1.29— the Laravel 12 upgrade bumped Pint 1.27→1.29 (newfully_qualified_strict_typesrule); applied repo-wide socomposer lintpasses. Formatting only.Review of the WIP (findings — follow-ups, not blocking the hardening)
normalizeCarouselDatafilters empty-srcslides on every edit/render (page-form.js/page-fullscreen.js), so "add slide" silently no-ops and editing one slide can overwrite a different one. Fix: don't filter in the edit/render hot path; only at final serialization.srchas no scheme validation (SiteChromeNormalizerService);chrome-builder.jssetPathlost itsArray.isArrayguard;decorateNavLinkpassesnulltoparse_url(PHP 8.3 deprecation). Chrome nav output is otherwise XSS-safe (scheme-allowlisted hrefs, Blade-escaped) and the JSON round-trip is loss-free.Removed two junk artifacts that were in the tree (
html_public/storage 2duplicate symlink, a runtime-generatedhtml_public/modules/.htaccess) and gitignored/html_public/modules.Full suite green (146 passed);
composer lintpasses.