',
+ str_starts_with($status, 'active_') ? 'success' : 'warning',
+ $this->escapeHtml((string) $message)
+ );
+ }
+
+ /**
+ * Resolve the scope selected in Magento's configuration UI.
+ *
+ * @return array{0: string, 1: string|null}
+ */
+ private function getSelectedScope(): array
+ {
+ $storeCode = (string) $this->getRequest()->getParam('store', '');
+ if ($storeCode !== '') {
+ return [ScopeInterface::SCOPE_STORE, $storeCode];
+ }
+
+ $websiteCode = (string) $this->getRequest()->getParam('website', '');
+ if ($websiteCode !== '') {
+ return [ScopeInterface::SCOPE_WEBSITE, $websiteCode];
+ }
+
+ return [ScopeConfigInterface::SCOPE_TYPE_DEFAULT, null];
+ }
+}
diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
index 0000000..758871e
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,114 @@
+# Changelog
+
+Notable changes to the Basicrum Analytics module are recorded here.
+
+## [Unreleased]
+
+### Added
+
+- Magento-aware PHPStan level 8, Magento coding standards, precise runtime
+ configuration types, and lowest/stable component-dependency CI checks.
+- Magento-version/image-pinned Mage-OS-mirror native test stack, production ZIP verification,
+ storefront/FPM installation binding, served-asset hashing, and two-visitor FPC
+ consent isolation tests. Development tooling is excluded from distribution.
+- Fail-closed required Beacon Endpoint and UUIDv4 Brum Site ID configuration,
+ scoped validation, and Admin monitoring status.
+- Manual consent-controlled loading with the public opt-in and opt-out
+ callbacks, plus deliberate immediate loading.
+- Optional query-string redaction and a bounded, opt-in Wait After Onload.
+- Focused PHP, real-loader browser, CI, and guarded disposable Magento test
+ foundations.
+- A guarded native-Magento `0.1.0` release gate covering upgrade, DI
+ compilation, static deployment, storefront beacons, and Admin rendering.
+- Native configuration-save tests covering validation, same-form HTTP policy,
+ scoped inheritance, and invalid imported values, with transaction rollback.
+- A frontend-only dynamic CSP collector that adds the validated effective
+ Beacon Endpoint origin to `connect-src` and `img-src` only while monitoring
+ is active.
+
+### Changed
+
+- **Breaking:** Magento 2 `p_type` now uses Magento 1's exact 27 named labels
+ for equivalent native pages, including `Checkout Success`, account/address,
+ wishlist, guest-order, and PayPal billing-agreement pages. HTTP 404 takes
+ precedence; unmapped native actions keep an explicit diagnostic fallback.
+ No historical beacon/reporting data is migrated. `p_gen=mage2` is unchanged.
+- Updated the Admin logo to the supplied 200 by 200 pixel Basicrum branding
+ asset, displayed at 48 by 48 pixels.
+- Visitor Consent and Privacy expand on every visit to the Admin configuration
+ page, keeping their settings and guidance immediately visible.
+- **Breaking:** normalized the technical Magento module identifier and PHP
+ namespace to the “Basicrum” spelling: `Basicrum_Analytics` and
+ `Basicrum\\Analytics`. The lowercase configuration paths remain unchanged.
+ This pre-release break was accepted because the extension has no
+ installations to migrate.
+- Removed the explicit Composer package version. Release versions now come
+ from immutable VCS tags. The breaking rename remains unreleased and requires
+ a new `0.1.0` tag rather than reuse of `0.0.2`.
+- Production HTTP Beacon Endpoints normalize to HTTPS; the explicit
+ development exception preserves HTTP.
+- The reviewed Boomerang 1.815.60 artifact and configuration now emit
+ `brum_site_id` while preserving `p_gen=mage2`.
+- Magento Config, Backend, CSP, and Store dependencies are declared explicitly
+ in Composer metadata and module sequencing.
+- Page-type detection injects Magento's concrete HTTP response and uses a
+ strict 404 status comparison. Public helper methods remain available with
+ Magento 1-aligned label comparisons.
+- Admin logo markup and styles live in a template and namespaced stylesheet;
+ the displayed Boomerang version uses the module's central version constant.
+- Removed the obsolete consent-mode selector and compatibility handling. The
+ consent-required switch and public manual callbacks remain unchanged; any old
+ database rows are ignored, not deleted.
+- Admin status and runtime eligibility share one decision. XML owns install
+ defaults; unused duplicate defaults and the stored Boomerang-version default
+ are removed. Tests focus on behavior instead of broad branding/source scans.
+
+### Fixed
+
+- Fresh disposable Magento provisioning no longer writes the Admin Usage setting
+ after disabling its owning module. Configuration failures still stop the job.
+- Release ZIPs and expected hashes now come from committed files; ignored local
+ files cannot enter the archive. Installed distributions reject extra development
+ and hidden files. The cache-isolation test populates a fresh entry after consent.
+- Persist the disposable stack's Git trust configuration across container recreation
+ and install npm dependencies as the host user. Document unlocked application
+ dependencies and the actual static-analysis component/PHP requirements.
+- Resolve store-to-website inheritance through public `StoreInterface` and
+ store-manager APIs, without relying on concrete store-model methods.
+- Browser integration checks now intercept at context scope and restrict
+ transport to the disposable store, including redirects. Unexpected traffic
+ fails the test; collectors are never added to the proxy allowlist.
+- The release gate checks installed Magento, PHP, Composer, MariaDB, and
+ OpenSearch against `baseline.env` before running upgrade or configuration writes.
+- Exclude test doubles from Composer's production classmap; CI now checks strict
+ optimized autoload generation and verifies that no test classes leak into it.
+- Remove inheritance controls and scope labels from display-only Admin status,
+ Boomerang version, and manual callback instructions, preserving inheritance
+ for saved settings.
+- Execute the PHP-rendered Wait After Onload script in real-Boomerang browser
+ tests, including delayed completion and withdrawal cancellation. Flaky browser
+ retries now fail CI rather than masking the initial failure.
+- Register the CSP collector alongside Magento's global collectors with an
+ explicit frontend-only runtime guard. The former frontend DI array replaced
+ core collectors, dropping existing policy sources and blocking checkout's
+ own requests under enforced CSP. Native browser checks now assert that core
+ policy sources survive, require an enforcing empty-cart checkout response,
+ exclude the collector from Admin CSP, and include an opt-in offline checkout
+ journey. Documentation reflects global registration, native header timing,
+ address-book redirects, and compiled-DI upgrade requirements.
+
+### Compatibility and deferred work
+
+- Active configuration paths are preserved. Stored legacy consent-mode values
+ are left untouched but no longer read; legacy values do not grant consent.
+- There is no configuration data migration. Existing enabled stores remain
+ inactive until the new required Brum Site ID is valid.
+- Automatic consent-provider adapters, full-page-cache invalidation analysis,
+ a broad optimizer matrix, release publishing, and the
+ central parity ledger's D-002 through D-004 items remain deferred. D-001's
+ Magento 2-to-Magento 1 label alignment is now implemented; a shared
+ cross-platform taxonomy and historical reporting migration remain deferred.
+
+## [0.0.2]
+
+- Previous module baseline.
diff --git a/Model/Config.php b/Model/Config.php
new file mode 100644
index 0000000..c00c997
--- /dev/null
+++ b/Model/Config.php
@@ -0,0 +1,246 @@
+getStatus($scopeType, $scopeCode);
+ if (!in_array($state, ['active_consent', 'active_immediate'], true)) {
+ return null;
+ }
+
+ return [
+ 'beacon_endpoint' => self::normalizeBeaconEndpoint(
+ $this->getString(self::XML_PATH_BEACON_ENDPOINT, $scopeType, $scopeCode),
+ $this->getBoolean(self::XML_PATH_DEVELOPMENT_MODE, false, $scopeType, $scopeCode)
+ ),
+ 'brum_site_id' => $this->getString(self::XML_PATH_BRUM_SITE_ID, $scopeType, $scopeCode),
+ 'consent_enabled' => $state === 'active_consent',
+ 'strip_query_string' => $this->getBoolean(
+ self::XML_PATH_STRIP_QUERY_STRING,
+ false,
+ $scopeType,
+ $scopeCode
+ ),
+ 'wait_after_onload' => $this->getBoolean(
+ self::XML_PATH_WAIT_ENABLED,
+ false,
+ $scopeType,
+ $scopeCode
+ ),
+ 'delay_ms' => self::normalizeWaitMilliseconds(
+ $this->scopeConfig->getValue(self::XML_PATH_WAIT_MS, $scopeType, $scopeCode)
+ ),
+ ];
+ }
+
+ /**
+ * Describe why the effective scope is active or inactive for admin feedback.
+ *
+ * @param string $scopeType
+ * @param string|int|null $scopeCode
+ */
+ public function getStatus(
+ string $scopeType = ScopeInterface::SCOPE_STORE,
+ $scopeCode = null
+ ): string {
+ if (!$this->getBoolean(self::XML_PATH_ENABLED, false, $scopeType, $scopeCode)) {
+ return 'disabled';
+ }
+
+ $rawEndpoint = $this->getString(self::XML_PATH_BEACON_ENDPOINT, $scopeType, $scopeCode);
+ if ($rawEndpoint === '') {
+ return 'missing_endpoint';
+ }
+ if (!self::isValidBeaconEndpoint($rawEndpoint)) {
+ return 'invalid_endpoint';
+ }
+
+ $rawSiteId = $this->getString(self::XML_PATH_BRUM_SITE_ID, $scopeType, $scopeCode);
+ if ($rawSiteId === '') {
+ return 'missing_site_id';
+ }
+ if (!self::isValidBrumSiteId($rawSiteId)) {
+ return 'invalid_site_id';
+ }
+
+ $consentRequired = $this->getBoolean(
+ self::XML_PATH_CONSENT_ENABLED,
+ true,
+ $scopeType,
+ $scopeCode
+ );
+
+ return $consentRequired ? 'active_consent' : 'active_immediate';
+ }
+
+ /**
+ * Validate a collector URL without accepting executable URL schemes.
+ *
+ * @param mixed $value
+ */
+ public static function isValidBeaconEndpoint(mixed $value): bool
+ {
+ if (!is_string($value) || $value === '' || trim($value) !== $value) {
+ return false;
+ }
+
+ if (filter_var($value, FILTER_VALIDATE_URL) === false) {
+ return false;
+ }
+
+ // Native parsing deliberately matches validation and the CSP origin parser.
+ // phpcs:ignore Magento2.Functions.DiscouragedFunction.Discouraged
+ $parts = parse_url($value);
+ if (!is_array($parts) || empty($parts['scheme']) || empty($parts['host'])) {
+ return false;
+ }
+
+ // Credentials would be exposed in storefront configuration, while a
+ // fragment is never part of an HTTP request. Query strings remain
+ // supported for compatibility with collectors that require them.
+ if (array_key_exists('user', $parts)
+ || array_key_exists('pass', $parts)
+ || array_key_exists('fragment', $parts)
+ ) {
+ return false;
+ }
+
+ return in_array(strtolower((string) $parts['scheme']), ['http', 'https'], true);
+ }
+
+ /**
+ * Validate a Brum Site ID as an RFC 4122 UUIDv4.
+ *
+ * @param mixed $value
+ */
+ public static function isValidBrumSiteId(mixed $value): bool
+ {
+ return is_string($value) && preg_match(self::BRUM_SITE_ID_PATTERN, $value) === 1;
+ }
+
+ /**
+ * Normalize only explicit boolean values. Unknown values fail to the caller's default.
+ *
+ * @param mixed $value
+ * @param bool $default
+ */
+ public static function normalizeBoolean(mixed $value, bool $default): bool
+ {
+ if ($value === true || $value === 1 || $value === '1') {
+ return true;
+ }
+
+ if ($value === false || $value === 0 || $value === '0') {
+ return false;
+ }
+
+ return $default;
+ }
+
+ /**
+ * Clamp a configured delay to the supported zero-to-30-second range.
+ *
+ * @param mixed $value
+ */
+ public static function normalizeWaitMilliseconds(mixed $value): int
+ {
+ if (!is_scalar($value) || !is_numeric($value)) {
+ return 0;
+ }
+
+ return min(self::MAX_WAIT_MS, max(0, (int) $value));
+ }
+
+ /**
+ * Read a trimmed scoped string.
+ *
+ * @param string $path
+ * @param string $scopeType
+ * @param string|int|null $scopeCode
+ */
+ private function getString(string $path, string $scopeType, $scopeCode): string
+ {
+ return trim((string) $this->scopeConfig->getValue($path, $scopeType, $scopeCode));
+ }
+
+ /**
+ * Apply the same HTTPS policy at save time and runtime after validation.
+ *
+ * @param string $endpoint
+ * @param bool $httpAllowed
+ */
+ public static function normalizeBeaconEndpoint(string $endpoint, bool $httpAllowed): string
+ {
+ return !$httpAllowed && stripos($endpoint, 'http://') === 0
+ ? 'https://' . substr($endpoint, 7)
+ : $endpoint;
+ }
+
+ /**
+ * Read an explicitly supported boolean, or the caller's safe default.
+ *
+ * @param string $path
+ * @param bool $default
+ * @param string $scopeType
+ * @param string|int|null $scopeCode
+ */
+ private function getBoolean(
+ string $path,
+ bool $default,
+ string $scopeType,
+ $scopeCode
+ ): bool {
+ return self::normalizeBoolean(
+ $this->scopeConfig->getValue($path, $scopeType, $scopeCode),
+ $default
+ );
+ }
+}
diff --git a/Model/Csp/BeaconPolicyCollector.php b/Model/Csp/BeaconPolicyCollector.php
new file mode 100644
index 0000000..0999258
--- /dev/null
+++ b/Model/Csp/BeaconPolicyCollector.php
@@ -0,0 +1,91 @@
+appState->getAreaCode() !== Area::AREA_FRONTEND) {
+ return $defaultPolicies;
+ }
+ } catch (LocalizedException $exception) {
+ // CLI/bootstrap contexts may not have selected an area yet.
+ return $defaultPolicies;
+ }
+
+ $runtimeConfig = $this->config->getRuntimeConfig();
+ if ($runtimeConfig === null) {
+ return $defaultPolicies;
+ }
+
+ $origin = $this->getOrigin((string) $runtimeConfig['beacon_endpoint']);
+ if ($origin === null) {
+ return $defaultPolicies;
+ }
+
+ foreach (self::DIRECTIVES as $directive) {
+ $defaultPolicies[] = new FetchPolicy($directive, false, [$origin]);
+ }
+
+ return $defaultPolicies;
+ }
+
+ /**
+ * Extract a fetch-policy origin from the validated effective endpoint.
+ *
+ * @param string $endpoint
+ */
+ private function getOrigin(string $endpoint): ?string
+ {
+ // Use the same native URL parser as Config's endpoint validation.
+ // phpcs:ignore Magento2.Functions.DiscouragedFunction.Discouraged
+ $parts = parse_url($endpoint);
+ if (!is_array($parts) || empty($parts['scheme']) || empty($parts['host'])) {
+ return null;
+ }
+
+ $scheme = strtolower((string) $parts['scheme']);
+ if (!in_array($scheme, ['http', 'https'], true)) {
+ return null;
+ }
+
+ $origin = $scheme . '://' . $parts['host'];
+ if (isset($parts['port'])) {
+ $origin .= ':' . $parts['port'];
+ }
+
+ return $origin;
+ }
+}
diff --git a/Model/PageTypeDetector.php b/Model/PageTypeDetector.php
index de706d3..5287f5c 100644
--- a/Model/PageTypeDetector.php
+++ b/Model/PageTypeDetector.php
@@ -1,17 +1,58 @@
'404 Not Found',
+ 'cms_index_defaultnoroute' => '404 Not Found',
+ 'cms_index_index' => 'Home',
+ 'cms_page_view' => 'CMS Page',
+ 'catalog_category_view' => 'Category',
+ 'catalog_product_view' => 'Product',
+ 'catalogsearch_result_index' => 'Search',
+ 'catalogsearch_advanced_index' => 'Advanced Search',
+ 'checkout_cart_index' => 'Cart',
+ 'checkout_index_index' => 'Checkout',
+ 'checkout_onepage_success' => 'Checkout Success',
+ 'customer_account_login' => 'Login',
+ 'customer_account_create' => 'Register',
+ 'customer_account_index' => 'Account',
+ 'customer_account_logoutsuccess' => 'Logout Success',
+ 'contact_index_index' => 'Contact',
+ 'sales_guest_form' => 'Orders and Returns',
+ 'customer_account_edit' => 'Customer Account Edit',
+ 'sales_order_view' => 'Order View',
+ 'paypal_billing_agreement_index' => 'Billing Agreements',
+ 'paypal_billing_agreement_view' => 'Billing Agreement View',
+ 'sales_guest_view' => 'Guest Order View',
+ 'customer_address_form' => 'Customer Address Edit',
+ 'customer_address_index' => 'Customer Address List',
+ 'wishlist_index_configure' => 'Wishlist Item Configure',
+ 'wishlist_index_index' => 'Wishlist Items List',
+ 'sales_order_history' => 'Order History',
+ 'customer_account_forgotpassword' => 'Forgot Password',
+ ];
+
+ /**
+ * Initialize detection from Magento's final request and response.
+ *
+ * @param HttpRequest $request
+ * @param HttpResponse $response
+ */
public function __construct(
private HttpRequest $request,
- private ResponseInterface $response
+ private HttpResponse $response
) {
}
@@ -22,36 +63,20 @@ public function __construct(
*/
public function getPageType(): string
{
- // Check for error pages first
- if ($this->response->getStatusCode() == 404) {
- return '404_not_found';
+ // A missing entity can return 404 even when its action is otherwise known.
+ if ($this->response->getStatusCode() === 404) {
+ return '404 Not Found';
}
- // Get full action name
- $fullActionName = $this->request->getFullActionName();
-
- // Common page types based on full action name
- $pageTypeMap = [
- 'cms_index_index' => 'home',
- 'cms_page_view' => 'cms_page',
- 'catalog_product_view' => 'product',
- 'catalog_category_view' => 'category',
- 'checkout_index_index' => 'checkout',
- 'checkout_cart_index' => 'cart',
- 'customer_account_login' => 'customer_login',
- 'customer_account_create' => 'customer_register',
- 'customer_account_index' => 'customer_account',
- 'sales_order_history' => 'order_history',
- 'contact_index_index' => 'contact',
- 'catalogsearch_result_index' => 'search_results',
- ];
+ $fullActionName = strtolower($this->request->getFullActionName());
- if (isset($pageTypeMap[$fullActionName])) {
- return $pageTypeMap[$fullActionName];
+ // Native Http returns "__" when route/controller/action are all unset.
+ if ($fullActionName === '' || trim($fullActionName, '_') === '') {
+ return 'unknown';
}
- // Default fallback
- return 'unmapped_' . $fullActionName;
+ // Do not infer a type from generic layout handles or URL/entity parameters.
+ return self::PAGE_TYPES[$fullActionName] ?? 'unmapped_' . $fullActionName;
}
/**
@@ -61,7 +86,7 @@ public function getPageType(): string
*/
public function isHomePage(): bool
{
- return $this->getPageType() === 'home';
+ return $this->getPageType() === 'Home';
}
/**
@@ -71,7 +96,7 @@ public function isHomePage(): bool
*/
public function isProductPage(): bool
{
- return $this->getPageType() === 'product';
+ return $this->getPageType() === 'Product';
}
/**
@@ -81,6 +106,6 @@ public function isProductPage(): bool
*/
public function isCheckoutPage(): bool
{
- return $this->getPageType() === 'checkout';
+ return $this->getPageType() === 'Checkout';
}
}
diff --git a/Model/System/Config/Backend/BeaconEndpoint.php b/Model/System/Config/Backend/BeaconEndpoint.php
new file mode 100644
index 0000000..83a117e
--- /dev/null
+++ b/Model/System/Config/Backend/BeaconEndpoint.php
@@ -0,0 +1,134 @@
+ $data
+ */
+ public function __construct(
+ Context $context,
+ Registry $registry,
+ ScopeConfigInterface $config,
+ TypeListInterface $cacheTypeList,
+ private StoreManagerInterface $storeManager,
+ ?AbstractResource $resource = null,
+ ?AbstractDb $resourceCollection = null,
+ array $data = []
+ ) {
+ parent::__construct(
+ $context,
+ $registry,
+ $config,
+ $cacheTypeList,
+ $resource,
+ $resourceCollection,
+ $data
+ );
+ }
+
+ /**
+ * Validate the endpoint and apply the effective HTTP policy before saving.
+ *
+ * @throws LocalizedException
+ */
+ public function beforeSave()
+ {
+ $value = trim((string) $this->getValue());
+
+ if ($value !== '' && !Config::isValidBeaconEndpoint($value)) {
+ throw new LocalizedException(
+ __('Beacon Endpoint must be a valid HTTP or HTTPS URL without embedded credentials or a fragment.')
+ );
+ }
+
+ $this->setValue(Config::normalizeBeaconEndpoint($value, $this->isHttpAllowed()));
+
+ return parent::beforeSave();
+ }
+
+ /**
+ * Resolve the policy submitted in the same scoped configuration form.
+ */
+ private function isHttpAllowed(): bool
+ {
+ $groups = $this->getData('groups');
+ $field = $groups['developer']['fields']['development_mode'] ?? null;
+
+ if (is_array($field) && empty($field['inherit']) && array_key_exists('value', $field)) {
+ return Config::normalizeBoolean($field['value'], false);
+ }
+
+ $scope = (string) $this->getScope();
+ $scopeCode = (string) $this->getScopeCode();
+
+ if ($scope === ScopeInterface::SCOPE_STORES && $scopeCode !== '') {
+ if (is_array($field) && !empty($field['inherit'])) {
+ $websiteId = $this->storeManager->getStore($scopeCode)->getWebsiteId();
+ $websiteCode = (string) $this->storeManager->getWebsite($websiteId)->getCode();
+ return Config::normalizeBoolean(
+ $this->_config->getValue(
+ Config::XML_PATH_DEVELOPMENT_MODE,
+ ScopeInterface::SCOPE_WEBSITE,
+ $websiteCode
+ ),
+ false
+ );
+ }
+
+ return Config::normalizeBoolean(
+ $this->_config->getValue(
+ Config::XML_PATH_DEVELOPMENT_MODE,
+ ScopeInterface::SCOPE_STORE,
+ $scopeCode
+ ),
+ false
+ );
+ }
+
+ if ($scope === ScopeInterface::SCOPE_WEBSITES
+ && $scopeCode !== ''
+ && !(is_array($field) && !empty($field['inherit']))
+ ) {
+ return Config::normalizeBoolean(
+ $this->_config->getValue(
+ Config::XML_PATH_DEVELOPMENT_MODE,
+ ScopeInterface::SCOPE_WEBSITE,
+ $scopeCode
+ ),
+ false
+ );
+ }
+
+ return Config::normalizeBoolean(
+ $this->_config->getValue(Config::XML_PATH_DEVELOPMENT_MODE),
+ false
+ );
+ }
+}
diff --git a/Model/System/Config/Backend/BrumSiteId.php b/Model/System/Config/Backend/BrumSiteId.php
new file mode 100644
index 0000000..0358a34
--- /dev/null
+++ b/Model/System/Config/Backend/BrumSiteId.php
@@ -0,0 +1,34 @@
+getValue());
+
+ if ($value !== '' && !Config::isValidBrumSiteId($value)) {
+ throw new LocalizedException(
+ __('Brum Site ID must be a valid UUIDv4 copied from the Basicrum backoffice.')
+ );
+ }
+
+ $this->setValue($value);
+
+ return parent::beforeSave();
+ }
+}
diff --git a/Model/System/Config/Backend/WaitMilliseconds.php b/Model/System/Config/Backend/WaitMilliseconds.php
new file mode 100644
index 0000000..e5b1da5
--- /dev/null
+++ b/Model/System/Config/Backend/WaitMilliseconds.php
@@ -0,0 +1,23 @@
+setValue(Config::normalizeWaitMilliseconds($this->getValue()));
+
+ return parent::beforeSave();
+ }
+}
diff --git a/README.md b/README.md
index 7acd972..f68cfde 100644
--- a/README.md
+++ b/README.md
@@ -1,42 +1,309 @@
-# BasicRum Analytics for Magento 2
+# Basicrum Analytics for Magento 2
-BasicRum Analytics is a Magento 2 extension that helps you collect and analyze real user monitoring (RUM) data for your Magento store, providing insights into your website's performance from the user's perspective.
+Basicrum adds Boomerang real user monitoring (RUM) to a Magento 2 storefront.
+Monitoring is fail-closed: no Basicrum storefront scripts are emitted unless
+the module is enabled and the effective store-scope Beacon Endpoint and UUIDv4
+Brum Site ID are valid.
-## Requirements
+## Supported baseline
-- Magento Open Source or Commerce version 2.3.x or higher
-- PHP 7.2 or higher
+The Phase 1 integration baseline is Magento Open Source **2.4.7-p10** with
+**PHP 8.3** and Composer 2.10. Platform version requirements are declared in
+`tests/integration/baseline.env`; container images are pinned by digest. The full
+application dependency set is not locked: first-time provisioning resolves
+transitive Composer dependencies, so upstream changes can affect a new install.
+Focused PHP checks run on PHP 8.2,
+8.3, and 8.4. Composer metadata allows Magento framework 103.x so the module
+can be evaluated on adjacent Magento 2.4 release lines, but only the pinned
+PHP 8.3 combination is declared for disposable Magento integration testing.
+This combination has now been exercised with Luma and built-in full-page cache,
+including the installed distribution ZIP. Adobe Commerce, Hyvä, headless/PWA,
+Varnish, and other Magento/PHP combinations are **not** certified by that run.
+The source repository's `docs/QUALITY-AND-RELEASE-READINESS.md` records the exact
+verification scope, skips, and remaining release requirements.
## Installation
+Install a published package with Composer:
+
```sh
-composer require basicrum/basicrum-analytics
-bin/magento module:enable BasicRum_Analytics
+composer require basicrum/basicrum-analytics
+```
+
+For a manual source installation, place this module at the exact path below.
+The casing is required on case-sensitive filesystems:
+
+```text
+app/code/Basicrum/Analytics
+```
+
+Then enable and initialize the module:
+
+```sh
+bin/magento module:enable Basicrum_Analytics
bin/magento setup:upgrade
+bin/magento setup:di:compile
bin/magento cache:flush
```
+Regenerate compiled DI after installation or upgrade, including developer
+installations where DI was previously compiled. This applies changed constructor
+metadata and the global CSP collector registration. In production mode, deploy
+static content using the store's normal deployment process as well.
+
## Configuration
-1. Log in to your Magento Admin Panel
-2. Navigate to **Stores > Configuration > BasicRum Analytics**
-3. Configure the following options:
- - **Enable Module**: Set to "Yes" to enable the extension
- - **Beacon Endpoint**: Enter the URL where the data should be sent. This will be the endpoint where a BasicRUM beacon catcher is running.
+Open **Stores > Configuration > Basicrum Analytics**. Every setting supports
+Magento default, website, and store inheritance. Display-only status, version,
+and callback instructions have no inheritance controls or stored values.
+Visitor Consent and Privacy open expanded each time you visit the page. You
+can collapse them while working; they reopen on your next visit.
+
+Required settings:
+
+- **Enable Basicrum**: new installations default to No.
+- **Beacon Endpoint**: a valid HTTP or HTTPS collector URL without embedded
+ credentials or a fragment. Endpoint query strings remain supported for
+ compatibility. HTTPS is enforced unless the explicit development exception
+ is enabled.
+- **Brum Site ID**: a UUIDv4 copied from the Basicrum backoffice.
+
+If any effective value is disabled, missing, malformed, or unsafe, the
+Monitoring Status row explains the inactive state and the storefront template
+emits nothing. Values are validated on save and again at render time so
+programmatic or stale configuration cannot bypass the runtime gate.
+
+Collection controls:
+
+- **Require Consent Before Monitoring** defaults to Yes. Select No only for a
+ deliberate immediate-loading policy.
+- **Strip Query Strings** defaults to No. When enabled, Boomerang replaces
+ complete query strings in page, navigation, referrer, and resource URLs with
+ `?qs-redacted` before beacon transmission.
+- **Wait After Onload** defaults to No with a zero delay. Its configured delay
+ is bounded to 30,000 milliseconds.
+- **Allow HTTP Beacon Endpoint** defaults to No and is intended only for local
+ development. Without it, saved and effective HTTP endpoints are upgraded to
+ HTTPS. Magento classifies this exception as environment-specific: configuration
+ dumps put it in `app/etc/env.php`, not shared `app/etc/config.php`. Do not promote
+ development `env.php` values to production. This follows Magento's native
+ [configuration deployment rules](https://experienceleague.adobe.com/en/docs/commerce-operations/configuration-guide/deployment/technical-details).
+ Existing database values and previously exported files are not rewritten;
+ review any old shared export of `basicrum/developer/development_mode` before
+ re-exporting configuration. The default, configuration path, and scope
+ inheritance are unchanged.
+
+The runtime emits Magento 1's exact named `p_type` values for equivalent
+Magento 2 pages (for example, `Home`, `Product`, `Search`, and
+`Checkout Success`), while retaining `p_gen=mage2` and the configured
+`brum_site_id`. Unknown actions use `unmapped_`;
+an unavailable action uses `unknown`. The [page-type alignment notes](docs/PAGE-TYPE-ALIGNMENT.md)
+list all mappings, native-route adaptations, and reporting impact.
+Boomerang uses `instrument_xhr=false`,
+Continuity and ResourceTiming with `splitAtPath`, and Secure/SameSite Strict
+cookie settings, matching the reviewed Basicrum configuration.
+
+When the effective runtime configuration is active, the module adds only the
+normalized Beacon Endpoint origin (scheme, host, and optional port) to the
+storefront `connect-src` and `img-src` CSP policies. Paths and query strings
+are not copied into CSP. Inactive or invalid configuration adds no collector
+origin. This covers Boomerang's send-beacon/XHR and image-fallback transports;
+it does not weaken other directives or add a wildcard.
+
+## Consent integration
+
+Phase 1 provides a manual, page-level callback contract. Basicrum does not
+display a banner, decide whether consent is legally required, infer consent
+from a cookie or legacy mode string, or persist its own consent decision.
+
+When the site's external consent tool authoritatively allows performance
+monitoring on the current page, call:
+
+```js
+if (typeof window.OPT_IN_BASICRUM_LOADER_WRAPPER === "function") {
+ window.OPT_IN_BASICRUM_LOADER_WRAPPER();
+}
+```
+
+On denial, expiry, or withdrawal, call:
+
+```js
+if (typeof window.OPT_OUT_BASICRUM_LOADER_WRAPPER === "function") {
+ window.OPT_OUT_BASICRUM_LOADER_WRAPPER();
+}
+```
+
+The consent wrapper is inert until allow. Repeated allow calls load Boomerang
+at most once. Denial before the first allow cleans measurement and legacy
+consent cookies without preventing a later allow on that page. Withdrawal
+during download prevents the arriving bundle from initializing. Withdrawal
+after initialization disables further collection, cancels a pending Wait After
+Onload timer, and removes `RT`, `BA`, `BRUM_CONSENT`, and `BOOMR_CONSENT`
+cookies where JavaScript can reach them. Data already transmitted cannot be
+retracted.
+
+After withdrawal once loading has started, re-grant requires a page reload.
+This intentionally prevents a same-page restart from a partially initialized
+state. The callbacks are registered by the footer loader; calls made before
+registration are not queued. Connect both allow and deny/change events in the
+site's consent tool on every page.
+
+Automatic consent-provider adapters are not part of Phase 1.
+
+## Upgrade behavior from 0.0.2
+
+No data migration renames, deletes, or heuristically rewrites stored settings.
+Review the following before enabling the upgraded module:
+
+- Magento 2 `p_type` labels now match Magento 1, including capitalization and
+ spaces. This intentionally changes existing report groupings; historical
+ beacons are not migrated and no legacy-label mode is provided. Update any
+ report filters and purge cached HTML after upgrading. WordPress and Magento
+ 1 labels are unchanged.
+
+- Before the first public release, the technical module identifier and PHP
+ namespace were normalized to the “Basicrum” spelling. This is an intentional
+ breaking rename; the supported identifiers are `Basicrum_Analytics` and
+ `Basicrum\\Analytics`. Lowercase `basicrum/*` configuration paths are
+ unchanged. The exact manual installation path is documented above.
+
+- Existing `basicrum/general/beacon_endpoint` values remain in place but now
+ receive save-time and runtime validation. Invalid values make monitoring
+ inactive. HTTP becomes HTTPS unless the development exception is explicit.
+- `basicrum/general/brum_site_id` is new and required. Existing enabled stores
+ stay inactive until a valid UUIDv4 value is configured at the appropriate
+ scope.
+- Existing `basicrum/consent/enabled=0` means deliberate immediate loading.
+ Value `1` means consent-controlled loading. Invalid or absent effective
+ values fail to consent-controlled behavior.
+- The obsolete `basicrum/consent/mode` selector and runtime handling are
+ removed. Existing database rows are left untouched but ignored, including
+ `manual`, `explicit`, `implicit`, `cookie`, and `gdpr`. Only the consent-required
+ switch controls loading; none of these old strings counts as consent. Manual
+ callbacks remain the supported integration.
+- The old five-second wait was hardcoded and had no stored setting. It is
+ replaced with `basicrum/performance/wait_after_onload` and `delay_ms`, both
+ defaulting to off/zero. Administrators who need the former timing must
+ explicitly enable it and enter 5000 ms.
+
+After changing module configuration, clean Magento configuration, layout,
+block HTML, and full-page caches. Production deployments must also publish the
+new static assets and invalidate any CDN or optimizer cache that can retain old
+HTML or JavaScript. Magento's versioned static asset URLs provide browser cache
+invalidation only after the deployment/content version changes.
+
+The template uses Magento's `SecureHtmlRenderer`, the loader and Boomerang
+assets are same-origin module assets, and the validated collector origin is
+added dynamically to storefront CSP. The disposable-store check exercises the
+actual layout and CSP path, but Phase 1 does not claim compatibility with a
+broad set of third-party script delay/combine/optimizer extensions.
+
+## Testing
+
+Fast checks:
+
+```sh
+docker run --rm -v "$PWD:/module:ro" -w /module php:8.3-cli php tests/php/run.php
+npm ci
+npm test
+```
+
+The fast PHP harness uses test doubles to cover defaults, validation, save
+normalization, runtime gates, scope inheritance, CSP origin policy, all Magento 1-aligned page-type mappings
+and fallbacks, template
+serialization/loader selection, and artifact provenance. Browser tests execute
+the packaged readable and minified loaders and the real bundled Boomerang
+against intercepted local requests. Global setup renders the actual PHP footer
+template using PHP 8.3 in Docker (Docker must be running), then the browser
+executes its inline configuration and Wait After Onload plugin. Set
+`BASICRUM_TEST_PHP=php` (or an absolute executable path) to use an installed
+PHP CLI instead. The Chromium CI job explicitly provisions PHP 8.3 and selects
+it through this setting; fixture rendering does not pull or start a Docker image
+in that job. A missing or failing selected PHP executable fails setup without
+falling back to Docker.
+Magento block/renderer doubles are used here; native rendering is covered by
+the separate integration suite. The checks cover pre-consent silence, one-time
+loading, denial and withdrawal races, cookie cleanup, query redaction, and
+beacon identity, delayed sending, and cancellation of the rendered wait timer.
+
+For the actual layout/template/static-content/CSP/storefront-to-beacon path,
+use the guarded disposable-store harness in `tests/integration/README.md`.
+It also exercises Magento's real Admin configuration-save model, backend
+validation, and default/website/store inheritance inside rolled-back database
+transactions. Browser traffic is limited to the disposable storefront/Admin;
+the expected beacon is fulfilled locally and unexpected destinations fail the
+test. External payment scripts must be disabled in that test installation.
+The native suite also renders a test-only, non-cacheable Magento page under
+enforcing CSP with inline scripts disabled. It checks matching bootstrap
+nonces, real first-party script execution and consent-gated beacons, and a
+blocked unnonced inline negative control. The fixture is never packaged with
+the extension. This is separate from the report-only homepage FPC checks; it
+does not certify nonce handling on cacheable pages or custom strict-dynamic
+policies. See the integration README for fixture installation and scope.
+The regular CI workflow runs strict Composer 2.10 validation and optimized
+production classmap checks plus the fast PHP and Chromium checks. The Chromium
+job uploads an HTML report with traces from failed test attempts, retained for
+seven days. A flaky pass still fails the job; retries do not hide failures.
+Additional CI checks run Magento-aware PHPStan level 8 and Magento coding
+standards against real Magento components, with lowest/stable dependency
+resolution on PHP 8.2–8.4 and a locked PHP 8.3 job. These component checks are
+not full-platform compatibility certification. The committed tooling lock requires
+PHP 8.3 or 8.4 and resolves framework 103.0.9 (the Magento 2.4.9 component line).
+The locally tested PHP 8.3 lowest resolution uses framework 103.0.7 (2.4.7 GA),
+not 2.4.7-p10. CI's lowest/stable jobs resolve afresh for their PHP version; their
+logs report the exact component versions. No PHPStan run against the native
+2.4.7-p10 dependency set is claimed. Run the locked tools locally on PHP 8.3/8.4:
+
+```sh
+composer --working-dir=tests/quality install --no-interaction --no-scripts
+sh tests/quality/check.sh
+```
-4. Click "Save Config" to apply the changes
-5. Clear the cache by going to **System > Cache Management** and clicking "Flush Magento Cache"
+The native CI workflow provisions a separate Magento-version/image-pinned stack from the
+anonymous Mage-OS mirror; it does not use production credentials. It disables
+external Braintree scripts only in that test stack. See the integration README
+for setup, synthetic credentials, localhost-only ports, and cleanup.
+Before tagging Phase 1 as `0.1.0`, the documented native
+release gate is also required: it requires a clean candidate checkout, verifies
+the distribution ZIP and registered installed module match it, and enforces all versions in `baseline.env`,
+then runs Magento upgrade, DI compilation, static deployment, native save tests,
+storefront/beacon assertions with a proven full-page-cache HIT, and an authenticated
+Admin rendering check. A temporary challenge binds the browser URL to that
+installation and its web PHP version; served loader/Boomerang bytes must match
+the candidate. A visitor with measurement cookies populates a fresh cache entry;
+another visitor's first request to that URL must be a HIT and remain silent until
+its own allow callback. It rechecks candidate identity afterward and records the
+tested commit SHA in its success output. Run `npm ci` first: the native runner
+uses only the installed Playwright binary, never an automatic download.
+The production archive is built from the clean Git commit, never loose working-tree
+files. It excludes tests, CI, developer tooling/configuration and generated output,
+and retains production code/assets and license notices. Installed-package checks
+reject all extra files, including leftover development directories and hidden files.
+No remote CI job is reported as passing merely because its definition was added.
-## Verification
+## Privacy and lifecycle notes
-To verify that the extension is working properly:
+Boomerang may collect page/navigation/referrer/resource URLs, performance
+timings, browser/network characteristics, and the configured Basicrum identity,
+then send them to the configured Beacon Endpoint. With consent-controlled
+loading, the Boomerang monitoring bundle, beacon transmission, and measurement
+cookies do not start before the current page receives allow. The inert consent
+wrapper is present so the external tool can signal that decision. Immediate
+mode has no such gate.
-1. Open your store in a web browser
-2. Open the browser's developer tools (F12)
-3. Check the Network tab for requests to the BasicRum collection endpoint
-4. Visit your BasicRum dashboard to confirm that data is being collected
+Disabling the module and cleaning page caches stops future script emission.
+Uninstall behavior and settings deletion are not automated in Phase 1; removing
+module files does not delete configuration or data already sent to a collector.
+Store operators remain responsible for their consent tool, privacy disclosure,
+collector access, retention, and deletion processes.
+## Third-party software and license
-## License
+The reviewed Boomerang 1.815.60 artifact and loader provenance, checksum, and
+BSD license are recorded in `THIRD-PARTY-NOTICES.txt` and
+`view/frontend/web/js/boomr/LICENSE.txt`.
-This extension is released under the [MIT License](LICENSE).
+The existing Composer metadata declares this module as MIT. This repository
+still does not contain an approved module-level license text; that pre-existing
+distribution gap must be resolved by the rights holder before release. Phase 1
+does not silently relicense the module.
diff --git a/THIRD-PARTY-NOTICES.txt b/THIRD-PARTY-NOTICES.txt
new file mode 100644
index 0000000..79a4b29
--- /dev/null
+++ b/THIRD-PARTY-NOTICES.txt
@@ -0,0 +1,42 @@
+# Third-Party Notices
+
+The Basicrum module's existing Composer metadata declares the module license as
+MIT. The module also distributes the following third-party software under its
+own license. Nothing in this notice changes or relicenses the module.
+
+## Boomerang 1.815.60
+
+- Project: Akamai Boomerang (https://github.com/akamai/boomerang)
+- Bundled file: `view/frontend/web/js/boomr/boomerang-1.815.60.cutting-edge.min.js`
+- License: BSD License
+- License text: `view/frontend/web/js/boomr/LICENSE.txt`
+- Source: commit `ead2783a33a2ce91205fe34f8fc992433faba9a2` in the `master` branch of
+ https://github.com/basicrum/boomerang, a fork of upstream Akamai Boomerang
+- Reproducible build: Node 12 (`.nvmrc`), `npm ci` against the source lockfile
+ (uglify-js 3.19.3), then
+ `grunt clean build --build-flavor=cutting-edge --build-number=815`
+- SHA-256: `90e8a1c85949b10d43e441efc3f0545f95e4384e26ee3042344a8b2b4110589c`
+- Banner note: the artifact banner stamps parent commit
+ `564759ed70de7801bb64de5e2025fb6ac049ff5f` because the final source change
+ was uncommitted when the shipped file was generated; the code matches
+ `ead2783a` byte for byte
+- Fork changes include removal of Long Tasks monitoring and deprecated FID,
+ Time to First Interaction changes, removal of unused utilities, and the
+ Basicrum configuration bootstrap.
+
+The Boomerang copyright notice and BSD license remain applicable to this file.
+
+## Boomerang Loader Snippet
+
+- Project: Akamai Boomerang (https://github.com/akamai/boomerang)
+- Bundled and adapted files: `view/frontend/web/js/loaders/boomerang-loader-v15.js`,
+ its minified build, and the standard loader block inside the consent wrapper
+- License: BSD License
+- License text: `view/frontend/web/js/boomr/LICENSE.txt`
+- Provenance: Boomerang Loader Snippet version 15, taken from the reviewed
+ Basicrum WordPress implementation at commit
+ `64f19d9e5a9fbe580c12c19796e86e3ad0dd17ff`
+
+The consent wrapper adds Basicrum lifecycle, withdrawal, wait cancellation,
+and legacy-cookie cleanup around the loader. Those adaptations do not change
+the license that applies to the original loader snippet.
diff --git a/ViewModel/Footer.php b/ViewModel/Footer.php
index 4dd6813..fa1b49f 100644
--- a/ViewModel/Footer.php
+++ b/ViewModel/Footer.php
@@ -1,35 +1,34 @@
$this->scopeConfig->getValue(
- 'basicrum/general/beacon_endpoint',
- ScopeInterface::SCOPE_STORE
- )
- ];
-
- return $config;
+ return $this->config->getRuntimeConfig();
}
/**
@@ -39,4 +38,12 @@ public function getPageType(): string
{
return $this->pageTypeDetector->getPageType();
}
+
+ /**
+ * Get the reviewed bundled Boomerang version.
+ */
+ public function getBoomerangVersion(): string
+ {
+ return Config::BOOMERANG_VERSION;
+ }
}
diff --git a/composer.json b/composer.json
index 806d241..2b2ba1c 100644
--- a/composer.json
+++ b/composer.json
@@ -1,8 +1,7 @@
{
"name": "basicrum/basicrum-analytics",
- "description": "BasicRUM Analytics Magento 2 module",
+ "description": "Basicrum real user monitoring for Magento 2",
"type": "magento2-module",
- "version": "0.0.2",
"authors": [
{
"name": "Tsvetan Stoychev",
@@ -10,13 +9,19 @@
}
],
"require": {
- "php": "^8.1|^8.2|^8.3",
- "magento/framework": "*",
- "magento/module-store": "*"
+ "php": ">=8.2 <8.5",
+ "magento/framework": "^103.0",
+ "magento/module-backend": "^102.0",
+ "magento/module-config": "^101.2",
+ "magento/module-csp": "^100.4",
+ "magento/module-store": "^101.1"
},
"archive": {
"exclude": [
- ".github"
+ "/.github", "/.gitignore", "/.gitattributes", "/.test-results",
+ "/docs", "/tests", "/node_modules", "/vendor", "/test-results", "/playwright-report",
+ "/package.json", "/package-lock.json", "/playwright.config.js", "/playwright.integration.config.js",
+ "/phpstan.neon", "/phpcs.xml"
]
},
"repositories": [
@@ -26,11 +31,18 @@
}
],
"autoload": {
+ "exclude-from-classmap": [
+ "/tests/",
+ "/.test-results/",
+ "/node_modules/",
+ "/test-results/",
+ "/playwright-report/"
+ ],
"files": [
"registration.php"
],
"psr-4": {
- "BasicRum\\Analytics\\": ""
+ "Basicrum\\Analytics\\": ""
}
},
"license": "MIT"
diff --git a/docs/CSP-CACHING-ADOPTION-NOTES.md b/docs/CSP-CACHING-ADOPTION-NOTES.md
new file mode 100644
index 0000000..7159a40
--- /dev/null
+++ b/docs/CSP-CACHING-ADOPTION-NOTES.md
@@ -0,0 +1,180 @@
+# CSP, caching and testing: adoption notes
+
+Recorded on 2026-09-23 after a read-only consultation with Opus 5.5 Max and
+Grok 4.7 through their CLIs, followed by independent Codex verification.
+Claude reported `claude-opus-5-5`; Grok reported `grok-4.7-build` in usage metadata.
+The reviewed module commit was `c2d6241efc21c16b7ef5723eae4052807c137c41`.
+
+**Status: future work only.** These are selected priorities, not implemented
+features, passing tests, or authorization for a release. The user requested
+that the decisions be retained for later. Production behavior is unchanged;
+[CR-D-002](DEFERRED-CODE-REVIEW-FINDINGS.md#cr-d-002-full-page-cache-invalidation-after-basicrum-configuration-changes)
+remains deferred.
+
+## Selected priorities
+
+| Priority | Proposed work | Reason | Estimated effort |
+| --- | --- | --- | --- |
+| 1 | Real Admin saves against warm full-page cache | Establish what visitors actually receive after privacy/configuration changes | Medium |
+| 2 | CSP assertions on existing real cache-HIT responses | Verify the effective collector origin and Magento's core policy sources survive cache hits | Small |
+| 3 | Clearer cache-operation and CSP guidance, including Admin copy | Explain cache refresh requirements and distinguish tested modes from unverified combinations | Small |
+
+These should be test/documentation improvements first. Do not replace the
+loader, consent model, native CSP renderer or collector as part of this work.
+
+### 1. Real Admin saves against warm full-page cache
+
+Use the guarded disposable Magento installation, with caching enabled and a
+real Admin form submission. The current
+[configuration-save harness](../tests/integration/config-save.php) tests
+validation and inheritance inside rolled-back transactions, disables
+configuration caching for that process, and invokes the save model rather
+than the complete Admin controller path. It cannot prove storefront cache
+refresh behavior.
+
+Start with a bounded set of representative transitions:
+
+- Immediate loading to required consent, including a store override returning
+ to an inherited consent requirement.
+- Enabled to disabled monitoring.
+- A Beacon Endpoint change, checking both rendered configuration and CSP.
+
+Acceptance criteria:
+
+- Establish a genuine warm FPC HIT before saving; do not manufacture headers.
+- Record the cache invalidation status, next response's HIT/MISS status,
+ rendered configuration/loader, CSP and actual tracking behavior separately.
+- Inspect behavior before and after Magento's normal Page Cache refresh.
+ Do not clean the cache between the save and the pre-refresh observation.
+- Use fresh browser contexts for post-save visits so a previous page's
+ initialized Boomerang cannot obscure the result.
+- Restore settings and clean test cache entries in a `finally` path. Keep the
+ test serial and isolated from other native configuration tests.
+- Do not describe one store-view test as proof of cross-store eviction or
+ Varnish/CDN purge. Expand scope only when necessary for an intended fix.
+
+A stale response confirms the practical impact of CR-D-002. It does not, by
+itself, select an automatic cache-clearing implementation.
+
+### 2. Check CSP on the HIT itself
+
+The existing [storefront tests](../tests/integration/storefront.spec.js)
+check CSP on an initial response, and separately require actual cache HITs
+for reload and independent-visitor scenarios. Add policy assertions to those
+HIT responses using the existing [CSP helpers](../tests/integration/csp.js).
+
+Verify core sources remain present and the effective collector origin appears
+in `connect-src` and `img-src`. Retain consent silence, single loading, beacon
+identity and visitor-isolation checks. Report-only policy checks must remain
+labelled report-only: they are not proof that a browser enforced the policy.
+
+This is a small test-only change; no production CSP changes are indicated.
+
+### 3. Explain operational requirements precisely
+
+After the native observations above, improve README/integration guidance and
+relevant Admin copy to explain:
+
+- Saving configuration and marking a cache invalid are not the same as
+ removing cached pages.
+- Disabling monitoring or tightening consent can require a Page Cache refresh
+ and any operator-managed CDN/optimizer purge before cached pages reflect
+ the change.
+- The current enforcing-CSP fixture is deliberately non-cacheable; the
+ homepage FPC checks are separate and report-only.
+- Existing pages already open in browsers are not refreshed by a cache purge.
+
+Keep [verification documentation](QUALITY-AND-RELEASE-READINESS.md) explicit
+about the baseline, executed checks and unsupported/unverified combinations.
+
+## Source findings and an important review correction
+
+Codex inspected relevant source copied read-only from the stopped disposable
+Magento Open Source 2.4.7-p10 installation. The reviewers also inspected the
+available 2.4.7 GA and/or 2.4.9-line quality components. Those component sets
+must not be presented as native 2.4.7-p10 execution evidence.
+
+On the inspected baseline:
+
+- The Admin configuration-save postdispatch observer calls
+ `Magento\PageCache\Observer\InvalidateCache`, which invokes
+ `TypeList::invalidate('full_page')`. That method records an invalidation
+ flag; `cleanType()` is a separate operation that removes cached data.
+- `Magento\Config\Model\Config::save()` dispatches the section-change event
+ with changed paths and scope information, but model-only invocation does
+ not run the Admin controller's postdispatch observers.
+- Built-in FPC stores the response in the result-rendering path. Magento adds
+ CSP later at `controller_front_send_response_before`. A cache HIT bypasses
+ normal rendering, so render-time nonce registration cannot be assumed to
+ survive. Route-specific CSP selection also depends on the full action name,
+ which requires attention when routing is bypassed on a HIT.
+- Varnish caches the completed HTTP response, so its header/nonce lifecycle
+ differs from built-in FPC. Cleaning local FPC does not prove a Varnish purge.
+
+Grok initially inferred that storing response headers meant a built-in HIT
+necessarily retained a matching CSP header and remained executable. Codex
+challenged the event ordering; Grok withdrew that conclusion and its proposed
+assertion requiring nonce reuse. Opus independently identified the ordering.
+**Do not encode nonce reuse as an intended secure contract.**
+
+These are source observations. No warm-cache Admin-save or cacheable
+enforcing-CSP scenario was executed during this consultation.
+
+## Later investigations and compatibility coverage
+
+1. **Bounded cacheable enforcing-CSP diagnostic.** Observe real MISS/HIT and
+ independent visitors, recording the effective policy mode, nonce/header
+ relationship and script execution. Check enforcement on every response;
+ falling back to report-only must not count as an enforcing pass. Use native
+ configuration in an isolated environment, not rewritten headers/assets.
+ Keep the existing non-cacheable fixture. A discovered platform limitation
+ calls for an explicit support boundary, not an improvised nonce bypass.
+2. **One additional native Magento/PHP baseline.** Select a currently supported
+ combination when this work is scheduled. Start with a manually triggered
+ lane if appropriate; component/static matrices do not replace native
+ rendering, DI compilation, asset deployment and browser execution.
+3. **Pinned Varnish lane.** Add before claiming Varnish verification. Require
+ genuine MISS/HIT evidence, policy consistency, visitor isolation and actual
+ purge behavior. This is a separate infrastructure investment, not equivalent
+ to Redis-backed built-in caching. External CDN management remains distinct.
+
+## Automatic cache clearing: disagreement and decision
+
+Opus favored Magento's normal invalidation/manual-refresh convention and
+warned about site-wide eviction, unaffected stores and external caches. Grok
+favored a narrow native clean if the warm-cache test confirmed stale output.
+
+**Decision: do not adopt automatic clearing yet.** First establish the real
+Admin-save behavior and the intended guarantee. If automatic refresh is later
+chosen, evaluate the smallest native solution for changed storefront settings,
+including no-op saves, inheritance, inactive-to-active transitions, configured
+Varnish, and CLI/import behavior. Do not assume per-store eviction exists, or
+that a local `full_page` clean purges Varnish/CDNs. Do not add an unconditional
+whole-cache flush or a new cache framework.
+
+## Peer practices not selected
+
+The useful lessons are native CSP integration, separation of shared HTML from
+visitor state, and tests of actual rendered behavior. Basicrum already uses
+`SecureHtmlRenderer`, a validated storefront-only endpoint collector and
+current-page consent callbacks without its own persisted consent decision.
+
+Do not borrow merely because a competitor has it:
+
+- Customer-data/AJAX storage for Basicrum consent.
+- HTML post-processing CSP helpers or hand-written nonce machinery.
+- Broad wildcard allowlists or disabling CSP/FPC to make scripts run.
+- `cacheable="false"` on normal storefront layouts.
+- GTM plumbing, server-side tracking or unrelated visitor identity features.
+
+An external bootstrap reading non-executable configuration could be examined
+if strict CSP plus cached pages becomes a product requirement. It is not
+selected here: it changes the loader integration contract and needs its own
+cross-plugin behavior review.
+
+## Consultation boundaries
+
+No implementation or competitor test suite was run during the review. No
+Magento service was started, configuration changed, or beacon transmitted.
+The decisions are recommendations, not a new compatibility certification.
+WordPress, Magento 1 and the shared parity ledger were left unchanged.
diff --git a/docs/DEFERRED-CODE-REVIEW-FINDINGS.md b/docs/DEFERRED-CODE-REVIEW-FINDINGS.md
new file mode 100644
index 0000000..ca688d8
--- /dev/null
+++ b/docs/DEFERRED-CODE-REVIEW-FINDINGS.md
@@ -0,0 +1,46 @@
+# Deferred code-review findings
+
+These findings were raised independently by the Grok and Fable 5.1 reviews of
+the Phase 1 implementation. CR-D-001 was subsequently implemented by adapting
+the compatible CSP work from upstream PR #13. CR-D-002 remains intentionally
+deferred and visible for follow-up planning.
+
+## CR-D-001: Dynamic Magento CSP policy for the Beacon Endpoint
+
+**Status:** Implemented.
+
+The storefront CSP collector now consumes the same validated effective runtime
+configuration as rendering. While monitoring is active, it adds only the
+normalized endpoint origin to `connect-src` and `img-src`, covering
+send-beacon/XHR and image fallbacks without wildcards. Disabled, incomplete,
+or invalid configuration adds nothing. The collector is registered in global
+dependency injection alongside Magento's core collectors; area-level array
+registration would replace them. An explicit frontend runtime guard keeps
+Basicrum's contribution out of Admin, API, cron, and unset-area contexts.
+
+Focused tests cover the inactive gate, production HTTPS normalization,
+path/query exclusion, port retention, both directives, and the explicit
+development HTTP exception. Native tests check core whitelist preservation,
+an enforcing checkout response, and the absence of Basicrum's origin in Admin.
+Execution results and baseline limitations are recorded in
+`OPUS-REVIEW-FOLLOWUPS.md`; the pinned pre-release gate remains required.
+
+## CR-D-002: Full-page-cache invalidation after Basicrum configuration changes
+
+**Status:** Deferred.
+
+The 2026-09-23 [CSP/caching adoption notes](CSP-CACHING-ADOPTION-NOTES.md)
+record the Opus/Grok consultation, native-core source findings and selected
+test-first priorities. They do not implement or resolve this finding.
+
+The cacheable footer embeds the effective endpoint, Brum Site ID, consent
+choice, query-redaction flag, and wait configuration. The module's backend
+models validate saved values but do not explicitly invalidate Magento
+full-page cache, so cached pages may retain earlier monitoring behavior after
+an administrator saves configuration.
+
+Follow-up must first verify Magento core and Varnish behavior on the disposable
+baseline, then implement correctly scoped invalidation for every storefront
+setting if core config-save behavior is insufficient. Until then, the README's
+upgrade guidance to clean configuration, layout, block HTML, full-page, CDN,
+and optimizer caches remains the operational mitigation.
diff --git a/docs/OPUS-REVIEW-FOLLOWUPS.md b/docs/OPUS-REVIEW-FOLLOWUPS.md
new file mode 100644
index 0000000..6e5a07f
--- /dev/null
+++ b/docs/OPUS-REVIEW-FOLLOWUPS.md
@@ -0,0 +1,83 @@
+# Opus review follow-ups — 2026-09-22
+
+All six approved follow-ups are implemented locally. These notes supplement,
+not replace, the earlier Phase 1 and page-type alignment execution records.
+
+## Implemented
+
+1. **Documentation (R-007/R-010):** replaced stale frontend-DI wording with
+ global collector registration plus a frontend runtime guard; corrected the
+ empty-address-book redirect/forward description and native layout header
+ timing; added compiled-DI regeneration to installation/upgrade guidance.
+2. **CSP regressions (R-007/R-008):** reject collector-array replacement in
+ every area DI file. Native checks require an enforcing checkout response
+ before following its empty-cart redirect, preserve core `'self'` and a
+ known Magento PayPal whitelist source, and exclude Basicrum's collector
+ origin from Admin CSP at default, website, and store scope.
+3. **Rendered wait behavior (R-005/R-008):** global browser setup invokes PHP
+ to render the production `footer.phtml` using the real Config, Footer, and
+ page detector. Tests execute that inline output with both packaged consent
+ loaders and the real Boomerang artifact. Controlled browser time verifies
+ silence before the delay, completion afterward, timer cancellation on
+ withdrawal, no same-page re-grant, and omission for default/off/zero settings.
+ The copied JavaScript wait-plugin fixture is removed.
+4. **Production autoload (R-009):** Composer excludes `/tests/` from classmap
+ generation. CI runs strict optimized production autoload generation and
+ checks that module classes remain present while test doubles are absent.
+5. **Display-only Admin rows (R-002/R-008):** a shared renderer removes scope
+ labels and inheritance/restore flags from Monitoring Status, Boomerang
+ Version, and Manual Callback API. Native website/store assertions also
+ prove actual configuration fields retain their inheritance checkboxes.
+6. **Flaky privacy checks (R-009):** CI keeps one browser retry for diagnostics,
+ but `failOnFlakyTests` makes a pass-on-retry fail the job. Native integration
+ tests already run without retries.
+
+No runtime consent contract, collection defaults, saved configuration paths,
+page labels, bundled assets, or license declarations were changed by these
+follow-ups. No configuration migration was introduced.
+
+## Checks actually run
+
+- Focused PHP harness: **16 groups passed on each of PHP 8.2, 8.3, and 8.4**.
+ Module and test PHP/PHTML syntax checks passed on all three versions.
+- `npm test`: minified-artifact verification and **36 Chromium tests passed**.
+ `CI=1 npm test` also passed all 36 without a retry.
+- Composer 2.10 in an isolated checkout copy: strict metadata validation and
+ `dump-autoload --optimize --strict-psr --no-dev --no-scripts --no-plugins
+ --no-interaction` succeeded. The generated map contained production classes
+ and no test doubles. This check does not install Magento dependencies.
+- Local Magento Open Source **2.4.9 from the Mage-OS mirror / PHP 8.5.6**:
+ dependency-injection compilation and config/layout/block/full-page cache
+ cleaning succeeded after updating the disposable module copy.
+- Native Chromium integration: **21 passed, 1 deliberately skipped**. This
+ included sample-data page/beacon checks, the new enforcing checkout response,
+ storefront consent/cache behavior, and authenticated Admin CSP/rendering at
+ default, website, and store scope. All measurement requests were intercepted.
+ The skipped test is the separately opted-in order-creating checkout journey;
+ the database order count stayed at 3 and saved Basicrum settings were unchanged.
+- JavaScript and integration-shell syntax checks and `git diff --check` passed.
+
+## Limits and remaining work
+
+- GitHub Actions itself was not run. The commands were exercised locally;
+ adding CI steps is not evidence of a remote workflow pass.
+- The declared **2.4.7-p10 / PHP 8.3** native release gate was not run: the
+ available installation is 2.4.9 / PHP 8.5.6. Supplemental local success does
+ not widen Composer's PHP constraint or certify proprietary Adobe Commerce.
+- The rendered-wait browser fixtures replace Magento's block/HTML renderer
+ with test doubles, but never replace the production PHP template or its
+ JavaScript. They are not a native Magento wait-enabled storefront run;
+ the local store's wait settings remained off/zero. Native layout/CSP is
+ independently exercised by the integration suite.
+- Checkout CSP is checked on its enforcing empty-cart redirect. The complete
+ order-creating journey was not rerun; its earlier successful run remains
+ documented in `PAGE-TYPE-ALIGNMENT.md`.
+- No fresh static deployment was needed because these follow-ups changed no
+ static assets. Package installation/release publishing, authenticated customer
+ journeys, automatic consent adapters, the optimizer matrix, and deferred
+ full-page-cache invalidation analysis remain outside this follow-up.
+- WordPress, Magento 1, and the central parity ledger were left unchanged.
+ The review-ID mappings above are completion notes for the central task.
+
+The existing branch work is preserved. Nothing was committed, pushed, deployed
+outside the disposable local installation, or published by this follow-up.
diff --git a/docs/PAGE-TYPE-ALIGNMENT.md b/docs/PAGE-TYPE-ALIGNMENT.md
new file mode 100644
index 0000000..6dc7eed
--- /dev/null
+++ b/docs/PAGE-TYPE-ALIGNMENT.md
@@ -0,0 +1,183 @@
+# Magento 1 page-type alignment
+
+## Decision and scope
+
+Magento 1 is the naming baseline specifically for `p_type`. Magento 2 emits
+the same 27 named values, including capitalization and spaces, for equivalent
+native pages. WordPress remains the behavioral reference for the other parity
+work; neither reference plugin is changed. `p_gen=mage2` remains unchanged.
+
+The reviewed source is Magento 1's `Helper/PageTypeDetector.php` in its
+Analytics community module, at repository
+commit `f90de1e78b024b98c533f38fae467beac5f5b2c0` (the detector's last change was
+`f8c4e2d5aa71ea87b5d34a2bb1dd9ed47634b99c`). Only its labels are adopted; the
+Magento 2 implementation uses the final native dispatched action and HTTP
+response, not Magento 1 layout handles or copied platform code.
+
+This is the explicitly approved Magento 2-to-Magento 1 portion of D-001,
+not a shared WordPress/Magento taxonomy or a historical reporting migration.
+The shared parity ledger remains unchanged for the central task to reconcile.
+
+## Exact mapping
+
+Action names below are normalized to lowercase before lookup. Unless noted,
+the corresponding Magento 1 action name is identical.
+
+| Emitted `p_type` | Magento 2 full action | Platform adaptation |
+| --- | --- | --- |
+| `404 Not Found` | `cms_noroute_index`, `cms_index_defaultnoroute` | Magento 1's primary route is `cms_index_noroute`; any HTTP 404 also wins over other mappings. |
+| `Home` | `cms_index_index` | |
+| `CMS Page` | `cms_page_view` | |
+| `Category` | `catalog_category_view` | |
+| `Product` | `catalog_product_view` | |
+| `Search` | `catalogsearch_result_index` | |
+| `Advanced Search` | `catalogsearch_advanced_index` | |
+| `Cart` | `checkout_cart_index` | |
+| `Checkout` | `checkout_index_index` | Magento 1 uses `checkout_onepage_index`. |
+| `Checkout Success` | `checkout_onepage_success` | Requires a valid completed-checkout session to render. |
+| `Login` | `customer_account_login` | |
+| `Register` | `customer_account_create` | |
+| `Account` | `customer_account_index` | |
+| `Logout Success` | `customer_account_logoutsuccess` | |
+| `Contact` | `contact_index_index` | Magento 1 uses `contacts_index_index`. |
+| `Orders and Returns` | `sales_guest_form` | |
+| `Customer Account Edit` | `customer_account_edit` | |
+| `Order View` | `sales_order_view` | |
+| `Billing Agreements` | `paypal_billing_agreement_index` | Magento 1 uses `sales_billing_agreement_index`. |
+| `Billing Agreement View` | `paypal_billing_agreement_view` | Magento 1 uses `sales_billing_agreement_view`. |
+| `Guest Order View` | `sales_guest_view` | |
+| `Customer Address Edit` | `customer_address_form` | Native address `edit` and `new` actions forward to `form`, including adding the first address. |
+| `Customer Address List` | `customer_address_index` | An empty address book redirects to `customer_address_new`, which forwards to the address form. |
+| `Wishlist Item Configure` | `wishlist_index_configure` | |
+| `Wishlist Items List` | `wishlist_index_index` | |
+| `Order History` | `sales_order_history` | |
+| `Forgot Password` | `customer_account_forgotpassword` | |
+
+The non-identical routes and address forwards were checked against installed
+Magento Open Source 2.4.9 controller implementations. PayPal's billing agreement
+controllers live in `Magento_Paypal`, not `Magento_Sales`. Native layout results
+apply their HTTP headers before rendering the footer. The CMS no-route actions
+are also mapped explicitly for route parity and defensive recognition if a
+custom response does not retain the usual 404 status; this is not a workaround
+for native header timing.
+
+Detection describes the rendered page, not the URL the visitor first requested.
+For example, a logged-out account request renders `Login`; an empty-cart
+checkout or a success URL without an order session redirects to `Cart`.
+
+## Deliberate fallback
+
+- An HTTP 404 response has priority and emits `404 Not Found`.
+- An unrecognized action emits `unmapped_`.
+ For example, `search_term_popular` emits `unmapped_search_term_popular`.
+- An unavailable action (including Magento's uninitialized `__`) emits
+ `unknown`.
+- Generic layout handles, URL slugs, product/category identifiers, and request
+ parameters are not used to guess a label. Magento 1-only route aliases are
+ not registered as Magento 2 equivalents.
+- Magento 1 has no named mapping for `catalogsearch_advanced_result`; that
+ action deliberately stays `unmapped_catalogsearch_advanced_result`.
+- The public helper methods remain available: `isHomePage`, `isProductPage`,
+ and `isCheckoutPage`. The last means the checkout page itself, not success.
+
+## Reporting and upgrade impact
+
+This intentionally changes Magento 2's beacon labels, with no legacy-label
+mode or stored-data migration:
+
+| Previous Magento 2 label | New label |
+| --- | --- |
+| `home` | `Home` |
+| `cms_page` | `CMS Page` |
+| `product` | `Product` |
+| `category` | `Category` |
+| `checkout` | `Checkout` |
+| `cart` | `Cart` |
+| `customer_login` | `Login` |
+| `customer_register` | `Register` |
+| `customer_account` | `Account` |
+| `order_history` | `Order History` |
+| `contact` | `Contact` |
+| `search_results` | `Search` |
+| `404_not_found` | `404 Not Found` |
+
+Newly recognized actions also stop emitting their previous `unmapped_*`
+values. Historical beacon records are not rewritten. If reports already exist,
+update filters/groupings or explicitly combine old/new Magento 2 labels on the
+reporting side. Magento 1 reporting vocabulary is unchanged. Unknown action
+diagnostics now normalize casing and distinguish unavailable actions from
+unrecognized ones.
+
+After updating the module, regenerate compiled dependency injection with
+`bin/magento setup:di:compile` if the installation uses compiled DI, including
+developer installations where it was previously generated. This also applies
+the CSP registration correction below. Manual source updates must not retain
+the obsolete collector declaration from `etc/frontend/di.xml`; that file is
+no longer distributed. Clean Magento's configuration, layout, block HTML,
+and full-page caches, plus any external cache retaining HTML. The label is embedded in the
+rendered page; cached HTML can otherwise retain old labels. No loader or
+Boomerang asset change is required for this mapping.
+
+## Verification
+
+`tests/php/run.php` checks every mapped action and exact label, mixed-case
+actions, view-model passthrough, 404 precedence, fallback, and all three helper
+methods. Existing CI runs these checks on PHP 8.2, 8.3, and 8.4. The real
+Boomerang fixture and native-storefront expectations use the new labels.
+
+The native browser matrix in `tests/integration/page-types.spec.js` checks
+actual rendered public pages and intercepted Boomerang beacons, including
+redirect destinations, the no-route page, and an unmapped native route.
+Sample-data cases cover CMS, category, and product pages. The separately
+opted-in offline checkout test can create a disposable order to verify both
+`Checkout` and `Checkout Success`. See `tests/integration/README.md` for setup,
+side effects, and execution flags. These tests never substitute `p_type` in
+the browser.
+
+### Checkout CSP correction discovered during verification
+
+The native checkout check uncovered an existing R-007 issue: registering the
+collector array in `etc/frontend/di.xml` replaced Magento's global collectors
+at the area DI stage. Checkout's enforced policy consequently omitted core
+sources such as `'self'`, blocking its own requests. Registration now lives
+in `etc/di.xml`, where Magento merges it with its original collectors. An
+explicit frontend area check keeps Basicrum's contribution out of Admin,
+API, cron, and unset-area contexts. Only the validated collector origin is
+added; core policies are not copied or loosened. The browser suite now checks
+the actual merged CSP header, and PHP tests cover the area guard.
+
+### Local execution record — 2026-09-22
+
+This records the original alignment run. Subsequent review fixes and newer
+test results are in `OPUS-REVIEW-FOLLOWUPS.md`; the order-creating journey was
+not repeated for those follow-ups.
+
+- PHP 8.2, 8.3, and 8.4: all 16 focused groups and all module/test PHP/PHTML
+ syntax checks passed. The focused harness also passed on the local store's
+ PHP 8.5.6.
+- `npm test`: minified-artifact verification and all 28 loader/real-Boomerang
+ browser tests passed.
+- Native Magento Open Source 2.4.9 from the Mage-OS mirror, on PHP 8.5.6 with
+ Luma sample data: all 21 Chromium integration tests passed. This includes
+ 18 page-route cases, the consent/cache/CSP storefront check, Admin rendering,
+ and the offline checkout journey. All collector requests were intercepted
+ locally; saved endpoint and Site ID settings were left unchanged.
+- The offline checkout produced synthetic pending order `000000003` in the
+ disposable local store. No live payment was used. Failed setup attempts also
+ left disposable quotes; no user data was deleted.
+- All 28 mapped actions resolved to real native controllers through Magento's
+ route configuration and action list. This verifies controller existence, not
+ every authenticated journey or its fixtures.
+- Native `setup:di:compile` and configuration/layout/block/full-page cache
+ cleaning passed. The final browser run used the regenerated DI; an interim
+ run failed on stale one-argument constructor metadata before recompilation.
+- `git diff --check` and integration shell syntax checks passed. The initial
+ offline checkout failure reproduced the CSP issue documented above; the
+ completed journey passed after the registration fix and recompilation.
+
+Not run: authenticated customer/address/order/wishlist and PayPal agreement
+browser journeys (no corresponding fixture suite was added); the declared
+2.4.7-p10/PHP 8.3 native release gate (the available store is 2.4.9/PHP 8.5.6);
+remote CI; release/package/publishing checks. Local 8.5 success does not widen
+Composer's declared `>=8.2 <8.5` constraint or certify proprietary Adobe
+Commerce. No reference plugin or shared ledger was edited.
diff --git a/docs/PHASE-1-PARITY-COMPLETION.md b/docs/PHASE-1-PARITY-COMPLETION.md
new file mode 100644
index 0000000..4f52aa5
--- /dev/null
+++ b/docs/PHASE-1-PARITY-COMPLETION.md
@@ -0,0 +1,122 @@
+# Magento 2 Phase 1 parity completion notes
+
+These notes are for incorporation into the central Basicrum parity task. The
+shared parity ledger and both reference plugins were intentionally left
+unchanged.
+
+## Review mapping
+
+- **R-001:** connected immediate and consent-controlled settings to loader
+ selection; added canonical public callbacks, fail-closed page-level consent,
+ idempotent allow, denial/withdrawal handling, wait cancellation, cookie
+ cleanup, and documented reload-to-regrant behavior.
+- **R-002:** added required UUIDv4 Brum Site ID, save-time and runtime identity
+ validation, fail-closed rendering, safe JSON, scoped settings, admin inactive
+ states, and `brum_site_id` beacons.
+- **R-005:** added HTTPS enforcement with explicit development HTTP exception,
+ optional query stripping, configurable zero-to-30-second Wait After Onload,
+ `instrument_xhr=false`, and the byte-identical reviewed Boomerang artifact
+ with provenance and BSD notices.
+- **R-006 (manual portion only):** documented and exposed the working manual
+ callback contract. Automatic Magento consent-provider adapters remain later
+ work.
+- **R-007 (foundation only):** retained `SecureHtmlRenderer`, used Magento
+ static asset URLs, added the validated effective Beacon Endpoint origin to
+ storefront `connect-src` and `img-src`, documented cache/static-deployment
+ behavior, and added a disposable-store check for the real layout/CSP path. A
+ broad optimizer and full-page-cache compatibility matrix remains later work.
+- **R-008:** added focused PHP/template tests, real-artifact browser tests with
+ intercepted beacons, and a guarded disposable Magento storefront-to-beacon
+ harness. The native pre-release gate additionally requires Magento upgrade,
+ DI compilation, static deployment, and authenticated Admin rendering.
+- **R-009 (Phase 1 foundation):** added CI for strict Composer 2.10, PHP, and
+ browser checks plus Boomerang provenance/checksum verification. The guarded
+ native Magento check is required separately before the new `0.1.0` tag.
+ Installable package build/smoke and publishing remain later work.
+- **R-010 (Phase 1 documentation):** aligned new customer copy to Basicrum and
+ Brum Site ID, reconciled runtime requirements, and documented privacy,
+ consent, cache, lifecycle, upgrade, and verification behavior.
+
+## Compatibility decisions
+
+There is no data migration. Existing paths and values remain stored. Explicit
+`consent/enabled=0` selects immediate loading; `1` selects consent-controlled
+loading. The obsolete consent-mode selector and runtime metadata have been
+removed; any old mode rows remain untouched in the database but are ignored and
+never grant consent. Existing sites require the new Site ID before
+monitoring resumes. Endpoint queries remain compatible, while embedded URL
+credentials and fragments now fail validation. The unstored hardcoded
+five-second wait becomes explicit off/zero settings; administrators can opt
+back into 5000 ms.
+
+Before the first public release, the technical module identifier and PHP
+namespace were intentionally normalized to the “Basicrum” spelling. This is a
+breaking identifier change, accepted because there are no extension
+installations to migrate. Lowercase `basicrum/*` configuration paths remain
+unchanged. The existing `0.0.2` tag is not reused: the guarded Phase 1 release
+candidate is `0.1.0`, and Composer derives the package version from that future
+immutable VCS tag.
+
+## Selective follow-up from upstream PR #13
+
+Five compatible ideas were adapted without replacing the WordPress-derived
+Phase 1 behavior: a dynamic storefront CSP collector; explicit Config,
+Backend, CSP, and Store module dependencies; the concrete Magento HTTP
+response type for page detection; template/CSS-based Admin logo rendering and
+centralized Boomerang version display; and an unreleased-first changelog.
+Consent removal, a token setting, asset renaming, page-vocabulary/interface
+changes, narrower runtime constraints, and license assertions from that pull
+request were not borrowed.
+
+## Deferred boundaries
+
+The later, explicitly approved Magento 2-to-Magento 1 `p_type` alignment is
+recorded in `PAGE-TYPE-ALIGNMENT.md` for central-task incorporation under
+D-001, with additional real-beacon coverage under R-008. It implements all 27
+Magento 1 labels using Magento 2 actions,
+preserves `p_gen=mage2`, and defines unmapped/unknown handling. This does not
+implement a WordPress-led shared taxonomy or migrate historical report data.
+Magento 1, WordPress, and the shared ledger are unchanged.
+
+Native checkout verification also found and corrected an R-007 CSP
+registration defect: the frontend DI array replaced Magento's core collectors.
+Global DI registration plus a frontend-only runtime guard now preserves them;
+the browser harness checks the real merged policy header. See the alignment
+notes for current execution results and limitations, separate from the original
+Phase 1 verification snapshot below.
+
+The approved Opus review follow-ups are recorded in `OPUS-REVIEW-FOLLOWUPS.md`.
+They strengthen R-007/R-008 CSP and rendered-wait coverage, R-009 production
+autoload/CI checks, R-002 scoped Admin presentation, and R-010 documentation.
+That report contains the newer local execution results and explicitly separates
+the supplemental 2.4.9 store from the still-required pinned release baseline.
+
+The subsequent reliability and simplification work is recorded in
+`RELIABILITY-AND-MAINTAINABILITY.md`: native configuration-save checks,
+fail-closed browser traffic, enforced release-baseline versions, and removal
+of obsolete consent-mode and duplicate configuration/test machinery.
+
+D-002 staff exclusion, D-003 placement/readiness queues, and D-004 cross-plugin
+wording remain deferred. Automatic provider adapters,
+a broad optimizer matrix, package/release publishing, and module-level license
+text approval are not completed by Phase 1. The full-page-cache finding
+CR-D-002 recorded in `DEFERRED-CODE-REVIEW-FINDINGS.md` remains explicitly
+deferred; CR-D-001's dynamic storefront CSP policy is implemented.
+
+## Verification recorded at completion
+
+- Focused PHP harness on PHP 8.2, 8.3, and 8.4: 15 groups passed on each version.
+- PHP syntax checks: all module and test PHP/PHTML files passed on PHP 8.2, 8.3, and 8.4.
+- XML well-formedness: module, admin, and layout XML passed.
+- Loader minification/provenance checks: passed.
+- Chromium browser suite: 28 tests passed against readable/minified loaders
+ and the real reviewed Boomerang artifact with intercepted local beacons.
+- Composer 2.10 strict validation (`--strict --no-check-publish`): valid;
+ release versions are derived from VCS tags rather than an explicit package
+ `version` field.
+
+No disposable Magento 2 installation was available in the workspace, so the
+native storefront/Admin release gate was added but not run. Its setup upgrade,
+DI compilation, static deployment, cached storefront, intercepted beacon, and
+authenticated Admin assertions therefore remain unverified here. Remote GitHub
+Actions were also not run from this local implementation.
diff --git a/docs/QUALITY-AND-RELEASE-READINESS.md b/docs/QUALITY-AND-RELEASE-READINESS.md
new file mode 100644
index 0000000..ad9b6ed
--- /dev/null
+++ b/docs/QUALITY-AND-RELEASE-READINESS.md
@@ -0,0 +1,246 @@
+# Quality and release-readiness follow-up — 2026-09-23
+
+Implements approved items **1, 4 and 5**. No WordPress/Magento 1 files or shared
+parity ledger entries were changed. No monitoring feature, consent policy,
+configuration path, page label or Boomerang artifact was changed.
+
+## Ideas adapted, not copied
+
+- [JustBetter Sentry](https://github.com/justbetter/magento2-sentry/tree/31614cb1fc685279e16b03d0491408caa88025f6):
+ Magento-aware level-8 PHPStan and lowest/stable dependency resolution. Basicrum
+ uses real Magento component types, never its lightweight test doubles. There
+ is no PHPStan baseline or blanket error suppression. Runtime configuration
+ has an explicit shared PHPDoc array shape rather than a new DTO/service layer.
+- [GENE New Relic RUM](https://github.com/genecommerce/module-newrelic-rum-page-type/tree/8c4903066a30b7fbb375fc8015044c9962dd3135):
+ repeatable native Magento integration alongside standards/static analysis.
+ Basicrum retains its stronger native scoped-save, consent, actual beacon,
+ enforcing/Admin CSP, cache-HIT and rendered-Admin assertions.
+- [Chessio Matomo](https://github.com/fnogatz/magento2-matomo/tree/3aaef162f129473c988d43154cf45567dd23609e):
+ keep visitor-specific state out of cacheable HTML. A native two-context test
+ checks a fresh cache entry populated by a request carrying measurement cookies,
+ then another visitor's first HIT, which must remain silent until its own grant.
+
+No competitor source code/assets were imported or relicensed. Boomerang's
+existing artifact and BSD notices remain unchanged.
+
+## Implementation and parity-review mapping
+
+- **R-007/R-008/R-009 — evidence tied to deployed code:** an ephemeral nonce
+ challenge binds the browser URL to the verified installation and checks FPM
+ PHP. Actual script response bodies must hash to the candidate loader and
+ Boomerang assets. Negative tests reject another root/PHP line, redirects,
+ stale bytes, unexpected scripts and failed asset responses. The challenge is
+ test-only and is removed even on failure.
+- **R-009 — maintainability:** Magento-aware PHPStan level 8 and Magento coding
+ standard 41, isolated locked tooling, PHP 8.2/8.3/8.4 lowest/stable CI plus a
+ locked PHP 8.3 job. Static analysis exposed a concrete-model `getWebsite()`
+ dependency; inheritance now uses the public `getWebsiteId()`/store-manager
+ contract. Production shape/type annotations are precise. Standards exceptions
+ are narrow and explained: pure Config validators, native URL parsing and two
+ indivisible translation keys. No blanket PHPStan suppression was added.
+- **R-009 — package/native gate:** matching Composer/Git export boundaries keep
+ development files out. Every ZIP entry and installed production file is
+ compared with committed-blob SHA-256 hashes before and after the gate. The ZIP is
+ installed through Magento's documented manual `app/code` path, followed by
+ upgrade, DI compilation, static deployment, native saves and browser checks.
+ The disposable stack disables Braintree rather than allowing external scripts
+ through the browser guard. CI uses the same harness.
+
+## Initial iteration verification record
+
+- Focused PHP: 18 groups pass on each of PHP 8.2, 8.3 and 8.4.
+- Fast Chromium/real-Boomerang and helper tests: 45 pass; minified artifacts match.
+- PHPStan level 8: passes on PHP 8.3 with both locked stable and lowest resolved
+ real Magento components. Magento coding-standard scan: zero code violations.
+ PHPCS itself reports upstream CSS/GraphQL sniff deprecations; these do not
+ represent ignored module findings.
+- Pinned native installation: version checks, upgrade, DI compilation, English
+ static deployment, 10 native save groups, and 19 native browser tests pass.
+ Four tests are intentionally skipped: three sample-data routes and the opt-in
+ order-creating checkout. No order was created and no collector was contacted.
+- Production ZIP built, manifest verified and installed in that native stack.
+ Both Composer and Git archives pass the same production-file manifest check.
+- The complete strict native gate passed against temporary clean test-snapshot
+ commit `00908c24fe43e81cf2bf7cf961c1b0b1fa7e1ff3`, including final installed/ZIP
+ rechecks. This commit exists only in an ignored local fixture repository, not
+ on the working branch and not as a release tag. Tested ZIP SHA-256:
+ `336ef485b29eaf3594f9caca50442eaa462cd91b1befe8b80f18da20dc46678d`.
+ The log is retained locally at `.test-results/native-gate-final.log`.
+- Strict Composer validation and optimized production autoload generation pass;
+ the classmap contains 13 production classes and no test doubles. Generated
+ verification directories are also excluded from production autoload discovery.
+- Remote workflows were added but have **not** been run for this uncommitted work.
+ The seven-cell remote component matrix is not being reported as locally run.
+
+## Opus review follow-ups — 2026-09-23
+
+All seven accepted suggestions are addressed without changing production runtime
+behavior or the reference plugins/shared ledger:
+
+1. **R-009, committed packaging:** build the ZIP with `git archive` from the clean
+ candidate commit. Compute expected SHA-256 hashes from committed blobs, not
+ local disk contents. Regression fixtures include both tracked-ignore and local
+ Git excludes, dirty files, and four deliberately damaged ZIPs.
+2. **R-007/R-008, cache isolation:** after an authoritative grant and a real
+ measurement cookie, a second tab of the first visitor populates a unique URL
+ and must receive MISS. Another visitor's first request to that URL must be HIT
+ and remain silent until its own grant. No server consent cookie or persisted
+ Basicrum decision is introduced.
+3. **R-009, accurate dependency claims:** deliberately take the narrower wording
+ option. Images and Magento versions are pinned, but no application dependency
+ lock or bit-for-bit reproducibility is claimed.
+4. **R-009, installed artifact boundary:** reject all extra installed files,
+ including development directories and hidden files. Checkout-based development
+ testing stays separate from strict distribution certification.
+5. **R-009, restart:** bake only `/module` Git trust into the image's system config;
+ document retained-volume `up -d` and rebuilding the image when needed.
+6. **R-009, ownership:** run bind-mounted `npm ci` with the host caller's UID/GID,
+ using a per-UID disposable cache rather than creating root-owned dependencies.
+7. **R-009, tool compatibility:** identify locked/locally tested component versions
+ below, document the locked PHP requirement, and print resolved versions in CI.
+
+Follow-up checks actually run:
+
+- PHP 8.2/8.3/8.4: 18 groups pass on each, including real Git commit/archive fixtures.
+ CI now installs Git explicitly for these tests.
+- Fast Chromium/helper suite: 45 pass, without retries; loader minification matches.
+- Native storefront subset: 9 pass (three consecutive runs of three tests).
+ The initial same-tab post-consent navigation produced a proxy-blocked beacon
+ attempt during document unload. The final same-cookie-jar/new-tab scenario
+ avoids that interception race without relaxing the guard or mocking Boomerang.
+- Four ZIP corruption regressions pass and are included in the locked quality CI job.
+- PHPStan level 8 and Magento coding standards pass with the locked PHP 8.3 tools;
+ PHPCS emits its existing upstream sniff-deprecation notices.
+- Full native gate passes: baseline checks, upgrade, DI compilation, static
+ deployment, 10 scoped-save groups, 19 browser checks and 4 explicit skips
+ (three sample-data routes and the order-creating checkout). No collector was
+ contacted and no order was created.
+- Gate candidate: isolated fixture commit
+ `48ed0392bb77ff544a53f84b65274c26019921e1`, not a working-branch commit/tag.
+ ZIP SHA-256: `e90cbc0d991ad9673c711813f6166bc84e9b18a0794916deacc4cf90590de749`.
+ Evidence: `.test-results/opus-followups-native-gate.log`.
+- Image rebuild and host-UID npm installation pass. Dependency files remain
+ host-owned (501:20 on this Mac); a Linux-host bind-mount run is not claimed.
+- Actual `down`/`up -d` recreation passes with retained application/database volumes:
+ Git trust comes only from `/etc/gitconfig`, baseline verification passes, and
+ all three storefront checks pass again. Current production files hash-match
+ the tested snapshot. The dedicated stack is stopped after verification.
+- PHP/PHTML lint, shell syntax, Compose validation, integration discovery and
+ `git diff --check` pass.
+
+The complete remote CI matrix and a new empty-volume provisioning run were not
+executed in this follow-up. The native run reused the dedicated disposable store.
+No source commit, push, release, deployment or license approval was made.
+
+## Fresh-provisioning CI correction — 2026-09-23
+
+The first [native CI run](https://github.com/basicrum/basicrum-magento-2/actions/runs/35846942665/job/107135254576)
+installed Magento successfully, then failed before installing Basicrum: the
+provisioning script wrote `admin/usage/enabled` after disabling the module that
+declares that field. Remove the redundant write, keeping Admin Analytics disabled
+and preserving fail-fast handling for real configuration errors.
+
+The new fast regression executes the actual provisioning script with CLI doubles;
+it checks both successful completion and failure propagation. All 46 fast checks
+and 18 focused PHP 8.3 groups pass. The actual `start.sh` bootstrap also completes
+on brand-new application/database volumes in the separate Compose project
+`basicrum-native-ci-fix-20260923`, including Nginx startup. Existing installation
+volumes are untouched. The local bootstrap log is retained at
+`.test-results/fresh-provision-ci-fix.log`; this is fresh-provisioning evidence,
+not a claim that a remote rerun has passed.
+
+## Enforcing CSP script execution — 2026-09-23
+
+Adds the missing native execution coverage alongside the earlier checkout-302
+header assertion. A separate `Basicrum_CspTest` fixture module supplies a native,
+non-cacheable page with route-specific enforcing CSP and inline scripts disabled.
+The release gate installs this fixture before upgrade/DI compilation. It is
+development-only, excluded from production archives and optimized classmaps.
+Production PHP, templates, loaders, configuration and CSP code are unchanged.
+
+The browser requires a real enforcing header, a matching production-bootstrap
+nonce, fresh nonces on two server renders, same-origin loader/Boomerang requests
+with verified bytes, consent silence, single loading, intercepted beacon identity
+and withdrawal cookie cleanup. No CSP violations or page errors are accepted
+in the positive case. A separate unnonced inline marker must be blocked with an
+enforcing violation; this detects accidentally bypassed browser CSP. No response
+headers, production assets or nonces are rewritten by the harness.
+
+Opus 5.5 was consulted through the local Claude CLI, with the returned model
+identity confirmed as `claude-opus-5-5`. Its patch review found no blocking
+issues. Follow-ups add fail-closed detection of stale fixture files without
+deleting them, canonicalize the release gate's installation root before changing
+directories, and accept both script CSP directive names in the negative control.
+A fast installer regression covers guarded installation, relative roots, refresh
+and stale-file rejection. A separate design review suggested enforcing CSP on
+the native cart instead; the isolated fixture keeps normal storefront/checkout
+policies unchanged and makes the test-only scope explicit.
+
+Checks actually run locally:
+
+- Fast Chromium/helper suite: 48 pass; packaged minification matches.
+- Focused PHP 8.3: 18 groups pass. Native configuration saves: 10 groups pass.
+- Native Magento 2.4.7-p10/PHP 8.3: upgrade, DI compilation and static deployment
+ pass; full browser suite: 21 pass, 4 existing explicit skips (sample-data routes
+ and order-creating checkout). Both new CSP tests run, without retries.
+- Strict optimized production classmap: 13 classes, no test fixture classes.
+- Fixture installer rejects a missing disposable guard, Magento root or stale
+ extra files, without deleting extras or enabling a rejected fixture.
+- Full archive/install/native release gate passes against isolated local test
+ snapshot `1699b5b44ee0ded40d036a829f1643d4bae1a790`, not a working-branch commit
+ or release tag. ZIP: 34 production files, SHA-256
+ `f1d157dc62c5f45ee60c1226581ae0ae35c93f8820bf8cb6d736a04d5dbc9eb8`.
+ Log: `.test-results/csp-native-gate.log`. Four archive-corruption checks pass.
+ This rerun includes the Opus follow-ups and exercises a relative `MAGENTO_ROOT`.
+- PHP/JavaScript and shell syntax checks and `git diff --check` pass.
+
+The enforcing fixture deliberately requires `no-store`, like checkout. Existing
+report-only homepage FPC/HIT and two-visitor checks remain unchanged. This run
+does not certify nonce handling on cacheable enforcing pages, Varnish/CDN caches,
+custom strict-dynamic policies, or a complete checkout/payment journey. Remote
+CI has not run for these uncommitted changes. This run reused the dedicated
+disposable installation; it did not reprovision fresh volumes. No release or
+push was performed.
+
+## Tested compatibility, not inferred compatibility
+
+| Combination | Evidence in this follow-up |
+| --- | --- |
+| Magento Open Source 2.4.7-p10, PHP CLI/FPM 8.3.31, Composer 2.10, MariaDB 10.11, OpenSearch 2.19.4 | Actual isolated installation, native configuration and browser checks |
+| Luma, en_US, built-in FPC, deployed static files | Actual Admin/storefront, enforcing checkout CSP, consent/beacon/cookie/redaction and two-visitor HIT checks |
+| PHP 8.2 / 8.4 | Focused PHP checks only; not native Magento runs |
+| Locked tools on PHP 8.3: framework 103.0.9 / backend 102.0.9 / config 101.2.9 / CSP 100.4.8 / store 101.1.9 | Static analysis against 2.4.9-line components; lock requires PHP 8.3/8.4 within the tooling project's constraint |
+| Locally resolved lowest tools on PHP 8.3: framework 103.0.7 / backend 102.0.7 / config 101.2.7 / CSP 100.4.6 / store 101.1.7 | Static analysis against 2.4.7 GA components, not the native 2.4.7-p10 dependency set |
+| CI lowest/stable resolutions on PHP 8.2/8.3/8.4 | Versions resolve afresh and are printed in CI; the full remote matrix has not been run locally |
+| Adobe Commerce, other Magento patch lines, Hyvä, headless/PWA, Varnish, CDN/optimizer combinations | Not verified |
+| Sample products/categories/CMS, authenticated customer journeys, successful checkout | Not exercised in this baseline fixture |
+
+## Remaining release requirements and deliberate boundaries
+
+The native Magento project version and Docker image digests are pinned, not the
+full application dependency closure. Provisioning still resolves transitive
+Composer dependencies; upstream updates/advisories can change or block a fresh
+install. No bit-for-bit reproducibility or committed application lock is claimed.
+
+The existing Composer MIT declaration is unchanged. A rights-holder-approved
+root LICENSE still needs the owner's copyright holder/year confirmation; no
+approval or legal ownership has been invented. This remains a release blocker,
+not a technical-test failure.
+
+The execution evidence above was collected before committing or pushing the
+pipeline implementation. Final release certification must be rerun against the exact clean commit to be tagged;
+a working-tree or isolated test-snapshot pass cannot authorize a later commit.
+Pushing CI definitions does not itself certify, publish, tag or deploy a release.
+
+The browser guard is request isolation, not a server-side egress firewall.
+The test stack has synthetic credentials, local-only HTTPS, SMTP disabled and
+no cron worker; do not expose it publicly or reuse its credentials. Existing
+local Magento installations are unchanged.
+The test stack was stopped after verification; its application/database volumes
+and local evidence were retained. No temporary challenge PHP files remain.
+
+Quantitative LCP/CLS/INP correctness and browser-lifecycle/Firefox/WebKit expansion
+remain outside approved items 1/4/5. CR-D-002 automatic cache invalidation remains
+deferred; operational cache cleaning is still required after configuration
+changes. A successful native technical gate is not a claim of universal theme,
+platform, performance-metric, legal or release readiness.
diff --git a/docs/RELIABILITY-AND-MAINTAINABILITY.md b/docs/RELIABILITY-AND-MAINTAINABILITY.md
new file mode 100644
index 0000000..2265de6
--- /dev/null
+++ b/docs/RELIABILITY-AND-MAINTAINABILITY.md
@@ -0,0 +1,87 @@
+# Reliability and maintainability follow-ups
+
+Implemented locally on 2026-09-22. These notes supplement the original Phase 1
+snapshot; they do not claim release certification.
+
+## Reliability findings
+
+1. **Fail-closed browser interception (R-008).** Every native browser suite uses
+ a shared context-level guard, including Admin and popups. Expected beacons
+ are fulfilled locally; unexpected destinations and misplaced measurement
+ payloads fail the test. Service workers and WebSockets are blocked. A small
+ Node built-in proxy restricts transport to the disposable storefront/Admin,
+ including redirect chains that bypass Playwright's normal routing. Five
+ loopback regression tests cover interception, popups, redirects, WebSockets,
+ and denied HTTPS tunnels. No production dependency was added.
+2. **Native configuration-save coverage (R-001/R-002/R-005/R-008).** Ten checks
+ use Magento's actual Admin save model and scoped configuration, not doubles:
+ defaults, backend rejection/normalization, same-form HTTP decisions, bounded
+ wait, website/store overrides and re-inheritance, consent, missing identity,
+ and invalid endpoint/identity imports. Each case rolls back its database
+ writes; the original Basicrum rows are verified afterward. Config caching is
+ disabled only in the test process. Third-party observer/cache side effects
+ are not transactionally reversible, so a disposable installation is required.
+3. **Enforced release baseline (R-009).** The release script checks the installed
+ Magento edition/patch, PHP, Composer, MariaDB, and OpenSearch before upgrade
+ or configuration writes. `baseline.env` remains the declared baseline, with
+ no override flag. Fast tests check each version mismatch and execute the
+ real shell gate with a failing baseline command to verify mutation ordering.
+
+## Worth simplifying
+
+1. **Remove obsolete consent modes (R-001/R-006/R-010).** Removed the selector,
+ source model, legacy options/notices, and unused runtime metadata. The
+ consent-required switch and public manual callbacks are unchanged. Existing
+ `basicrum/consent/mode` rows remain untouched but are no longer read. No old
+ string can grant consent, and no compatibility shim or data migration was added.
+2. **One configuration decision (R-002/R-005).** Admin status and storefront
+ eligibility now share `Config::getStatus()`. Endpoint HTTPS normalization is
+ shared between save and runtime. Removed unused parallel defaults; Magento
+ XML owns install defaults, and the existing constant owns Boomerang version.
+ Normalization still supplies fail-closed behavior for invalid runtime input.
+3. **Test behavior, not incidental source spelling (R-008/R-009).** Removed
+ repository-wide branding scans and assertions about exact shell snippets,
+ plus unused test doubles. Kept targeted package metadata, provenance,
+ behavioral shell-guard, native-rendering, and public-contract assertions.
+
+## Verification actually run
+
+- PHP 8.2, 8.3, and 8.4: 17 fast test groups passed on each; PHP/PHTML lint passed.
+- `CI=1 npm test`: 41 Chromium tests passed without retries, including the real
+ packaged readable/minified loaders, bundled Boomerang, rendered wait plugin,
+ and network-guard regressions. Minification checks passed.
+- Composer 2.10 strict validation and strict optimized production autoload:
+ passed in an isolated container copy; 13 production classes, no test doubles.
+- Local Magento Open Source 2.4.9 / PHP 8.5.6: DI compilation passed; all 10
+ native save checks passed and the original Basicrum rows were restored.
+ The existing legacy mode row was retained and the order count remained 3.
+ This newer installation is supplemental, outside the declared PHP baseline.
+- Final native browser run on that installation: **17 passed, 4 failed,
+ 1 skipped**. All four failures were the network guard blocking external
+ Braintree `client.min.js` / `paypal-checkout.min.js` requests. Admin rendering
+ (including the removed selector) and enforcing-checkout CSP passed. The full
+ native browser suite is therefore **not passing**. Earlier runs also exposed
+ those assets on other pages; timing changes how many tests observe them.
+- The actual baseline checker rejected the local 2.4.9 installation before any
+ release mutation, as required. Shell syntax and `git diff --check` passed.
+
+## Remaining verification and boundaries
+
+The local disposable store still loads external Braintree scripts. Disable
+those integrations in the test installation and rerun the native browser
+suite; no external-host exceptions were added and unrelated payment settings
+were not changed by this work. No live collector request was forwarded.
+
+The full release gate on Magento 2.4.7-p10 / PHP 8.3 has not run: that installation
+is not available locally. Positive native baseline/service-version checks,
+static deployment for this revision, and remote GitHub Actions are not reported
+as passing. Existing CI automatically picks up the new fast tests; native checks
+remain a separately required release gate. The opt-in order-creating checkout
+journey was not rerun, and no order was created.
+
+Automatic full-page-cache invalidation remains explicitly deferred as
+CR-D-002. No loader lifecycle, page-type vocabulary, callback, active setting
+path, namespace, license, or reference-plugin behavior was changed. WordPress,
+Magento 1, and the shared parity ledger were left unchanged. These notes are
+for the central parity task to incorporate. No release or deployment to a remote
+environment was performed for these follow-ups.
diff --git a/docs/REVIEW-VERIFICATION-FOLLOWUPS.md b/docs/REVIEW-VERIFICATION-FOLLOWUPS.md
new file mode 100644
index 0000000..fbe02a2
--- /dev/null
+++ b/docs/REVIEW-VERIFICATION-FOLLOWUPS.md
@@ -0,0 +1,69 @@
+# Joint-review verification follow-ups
+
+Later execution evidence and the resolved package/native-stack gaps are recorded
+in [QUALITY-AND-RELEASE-READINESS.md](QUALITY-AND-RELEASE-READINESS.md). The results
+below remain the historical 2026-09-22 record, not the current verification state.
+
+Implemented locally on 2026-09-22 after the Codex, Opus 5.5 Max, and focused
+Grok review. These notes are for the central parity task; the shared ledger
+and reference plugins were not changed.
+
+## Changes
+
+- **R-009: candidate identity.** The release gate requires a clean committed
+ module checkout, resolves Magento's registered module path, and hashes both
+ trees' package files. Stale, missing, extra, or internally symlinked source
+ fails before Magento writes. The gate repeats the identity checks afterward
+ and records the successful candidate SHA/tag in its output. Development/output
+ exclusions and copied-install requirements are documented in the integration
+ README. This is source verification, not an installable-artifact certification.
+- **R-007/R-008: actual cached-page evidence.** The storefront test warms the
+ anonymous cookie/vary context, requires a native FPC `HIT`, verifies fresh
+ consent and cookie/beacon silence on that response, then checks the cached
+ page's beacon identity and redaction. Missing/MISS/UNCACHEABLE responses fail.
+- **R-008/R-009: reproducible native runner and CI discovery.** The setup harness
+ requires the installed Playwright executable before configuration writes.
+ CI loads/discovers the integration tests without claiming native execution.
+- **R-005/R-009: environment-specific HTTP exception.** Magento's `TypePool`
+ marks only `basicrum/developer/development_mode` as environment-specific for
+ configuration export. Defaults, scopes, stored settings, and runtime policy
+ are unchanged. Previously exported files are not silently rewritten.
+
+## Verification actually run
+
+- PHP 8.2, 8.3, and 8.4: **18 groups passed on each**, including mismatched
+ package files, missing local runner, and release-guard mutation ordering.
+- `CI=1 npm test`: **43 checks passed without retries**, including the actual
+ readable/minified loaders, real Boomerang, negative cache-evidence assertions,
+ and the clean-candidate guard exercised against a temporary real Git repository.
+ Minification checks passed.
+- Integration `--list`: **22 tests discovered**. This checks loading only.
+- PHP/PHTML lint on PHP 8.3, shell syntax, and `git diff --check`: passed.
+- Magento 2.4.9 native DI XSD validation passed. Its actual `TypePool` accepted
+ the environment classification read from the candidate XML. This was an
+ isolated native-class check, not an `app:config:dump` or installed merged-DI run.
+- The actual candidate checker resolved the local installation's registered
+ module and **correctly rejected its different package copy**. Differences
+ included top-level metadata and `etc/di.xml`; PHP and browser assets matched.
+ The clean-checkout guard also correctly rejected this uncommitted worktree.
+- Targeted native storefront run: **1 passed, 1 failed**. Enforcing empty-cart
+ checkout CSP passed. The homepage test reached and passed the real FPC HIT,
+ fresh-consent silence, cookie cleanup, and redacted identity assertions, but
+ failed at teardown because the network guard blocked existing Braintree
+ scripts. The test remains red; no external-host exception was added.
+
+## Not run and remaining boundaries
+
+The full native gate on Magento 2.4.7-p10 / PHP 8.3 was not run: the pinned
+installation is unavailable, and the candidate was uncommitted during verification. The
+current local Magento 2.4.9 / PHP 8.5.6 store is supplemental only and was not
+resynced. The new native save-suite assertion for the installed `TypePool`,
+configuration export, DI compilation/static deployment for this revision,
+full native browser suite, Composer checks, and remote GitHub Actions were not
+rerun. The local native browser guard still requires removal of external
+payment scripts in the disposable installation before it can pass.
+
+Automatic cache invalidation (CR-D-002), cross-store/Varnish compatibility, and
+installable release-artifact verification remain outside this follow-up. No
+store settings were changed, no order was created, and no live beacon was
+forwarded. No release or remote deployment was performed.
diff --git a/etc/acl.xml b/etc/acl.xml
index ee48a8c..a520e94 100644
--- a/etc/acl.xml
+++ b/etc/acl.xml
@@ -7,7 +7,7 @@
-
+
diff --git a/etc/adminhtml/system.xml b/etc/adminhtml/system.xml
index 5b3dec9..ccdf185 100644
--- a/etc/adminhtml/system.xml
+++ b/etc/adminhtml/system.xml
@@ -3,45 +3,116 @@
xsi:noNamespaceSchemaLocation="urn:magento:module:Magento_Config:etc/system_file.xsd">
-
+
-
-
+
+
basicrum
- BasicRum_Analytics::basicrum_analytics
-
+ Basicrum_Analytics::basicrum_analytics
+
-
- BasicRum\Analytics\Block\Adminhtml\System\Config\Logo
-
- This field is used to display the BasicRum logo
+ Basicrum emits no storefront monitoring scripts unless it is enabled and the effective Beacon Endpoint and Brum Site ID are valid.
+
+ Basicrum\Analytics\Block\Adminhtml\System\Config\Logo
+
+ This field displays the Basicrum logo.
-
-
+
+
Magento\Config\Model\Config\Source\Yesno
+ New installations are disabled. Enabling still requires valid identity settings below.
-
+
+
+ Basicrum\Analytics\Block\Adminhtml\System\Config\Status
+
+
+ Basicrum\Analytics\Model\System\Config\Backend\BeaconEndpoint
+ Valid HTTP or HTTPS collector URL without embedded credentials or a fragment. Endpoint query strings remain supported. Required when Basicrum is enabled. HTTP is upgraded to HTTPS unless the explicit development exception is enabled. The effective collector origin is added to storefront connect-src and img-src CSP policies while monitoring is active.
+
+
+
+ Basicrum\Analytics\Model\System\Config\Backend\BrumSiteId
+ UUIDv4 copied from the Basicrum backoffice. Required when Basicrum is enabled.
-
-
- BasicRum\Analytics\Block\Adminhtml\System\Config\BoomerangVersion
+
+
+ Basicrum\Analytics\Block\Adminhtml\System\Config\BoomerangVersion
-
-
-
-
+
+
+ 1
+ Basicrum does not show a consent banner or persist its own consent decision. Connect your site's consent tool with both public callbacks when consent is required.
+
+
Magento\Config\Model\Config\Source\Yesno
+ Yes keeps Boomerang unloaded until an allow callback on each page. No starts monitoring immediately; choose No only when deliberate and permitted.
-
-
- BasicRum\Analytics\Block\Adminhtml\System\Config\ConsentMode
+
+
+ Basicrum\Analytics\Block\Adminhtml\System\Config\ReadOnlyField
+ window.OPT_IN_BASICRUM_LOADER_WRAPPER() only after your consent tool authoritatively allows performance monitoring on the current page. Call window.OPT_OUT_BASICRUM_LOADER_WRAPPER() for denial, expiry, or withdrawal. A denial before loading may be followed by allow on the same page. After withdrawal during download or after initialization, reload before re-granting. Calls made before the footer loader registers these functions are not queued.
+ ]]>1
+
+
+ 1
+
+
+ Magento\Config\Model\Config\Source\Yesno
+ When enabled, complete query strings in page, navigation, referrer, and resource URLs are replaced with ?qs-redacted before beacons are sent. URL paths remain.
+
+
+
+
+
+
+ Magento\Config\Model\Config\Source\Yesno
+ Delay the page-load beacon after onload. Disabled by default.
+
+
+
+ validate-number validate-zero-or-greater
+ Basicrum\Analytics\Model\System\Config\Backend\WaitMilliseconds
+ Zero disables the delay. Values are bounded to 30000 milliseconds (30 seconds).
+
+ 1
+
+
+
+
+
+
+
+ Magento\Config\Model\Config\Source\Yesno
+ Development-only exception for local testing. Keep disabled on production stores so HTTP endpoints are upgraded to HTTPS.
+
+
diff --git a/etc/config.xml b/etc/config.xml
new file mode 100644
index 0000000..4872f13
--- /dev/null
+++ b/etc/config.xml
@@ -0,0 +1,26 @@
+
+
+
+
+
+ 0
+
+
+
+
+ 1
+
+
+ 0
+
+
+ 0
+ 0
+
+
+ 0
+
+
+
+
diff --git a/etc/di.xml b/etc/di.xml
index 354eb16..f8e238a 100644
--- a/etc/di.xml
+++ b/etc/di.xml
@@ -1,5 +1,19 @@
-
-
+
+
+
+
+ 1
+
+
+
+
+
+
+
+ Basicrum\Analytics\Model\Csp\BeaconPolicyCollector
+
+
+
diff --git a/etc/module.xml b/etc/module.xml
index e88396f..adeed68 100644
--- a/etc/module.xml
+++ b/etc/module.xml
@@ -1,5 +1,12 @@
-
+
+
+
+
+
+
+
+
diff --git a/package-lock.json b/package-lock.json
new file mode 100644
index 0000000..3ecfaf3
--- /dev/null
+++ b/package-lock.json
@@ -0,0 +1,95 @@
+{
+ "name": "basicrum-magento-2-tests",
+ "version": "0.1.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "basicrum-magento-2-tests",
+ "version": "0.1.0",
+ "devDependencies": {
+ "@playwright/test": "1.62.1",
+ "uglify-js": "3.19.3"
+ },
+ "engines": {
+ "node": ">=20"
+ }
+ },
+ "node_modules/@playwright/test": {
+ "version": "1.62.1",
+ "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.62.1.tgz",
+ "integrity": "sha512-DTcUc8qii+cpHvtOwggMtBRMjKZHXYWdw8syRYu2vtzuq4Wxphqq4NfCs5Zt44L6mA8rfDfj+PHnxFc/FeK6mQ==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "playwright": "1.62.1"
+ },
+ "bin": {
+ "playwright": "cli.js"
+ },
+ "engines": {
+ "node": ">=20"
+ }
+ },
+ "node_modules/fsevents": {
+ "version": "2.3.2",
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
+ "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+ }
+ },
+ "node_modules/playwright": {
+ "version": "1.62.1",
+ "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.1.tgz",
+ "integrity": "sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "playwright-core": "1.62.1"
+ },
+ "bin": {
+ "playwright": "cli.js"
+ },
+ "engines": {
+ "node": ">=20"
+ },
+ "optionalDependencies": {
+ "fsevents": "2.3.2"
+ }
+ },
+ "node_modules/playwright-core": {
+ "version": "1.62.1",
+ "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.1.tgz",
+ "integrity": "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "playwright-core": "cli.js"
+ },
+ "engines": {
+ "node": ">=20"
+ }
+ },
+ "node_modules/uglify-js": {
+ "version": "3.19.3",
+ "resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.19.3.tgz",
+ "integrity": "sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "bin": {
+ "uglifyjs": "bin/uglifyjs"
+ },
+ "engines": {
+ "node": ">=0.8.0"
+ }
+ }
+ }
+}
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..1855baf
--- /dev/null
+++ b/package.json
@@ -0,0 +1,19 @@
+{
+ "name": "basicrum-magento-2-tests",
+ "version": "0.1.0",
+ "private": true,
+ "description": "Automated verification for the Basicrum Magento 2 module",
+ "engines": {
+ "node": ">=20"
+ },
+ "scripts": {
+ "build:loaders": "node tests/js/build-loaders.js",
+ "check:minified": "node tests/js/check-minified.js",
+ "test:browser": "playwright test",
+ "test": "npm run check:minified && npm run test:browser"
+ },
+ "devDependencies": {
+ "@playwright/test": "1.62.1",
+ "uglify-js": "3.19.3"
+ }
+}
diff --git a/phpcs.xml b/phpcs.xml
new file mode 100644
index 0000000..fec551f
--- /dev/null
+++ b/phpcs.xml
@@ -0,0 +1,19 @@
+
+
+ Magento coding standards for first-party production PHP and templates.
+
+
+
+ */Model/Config.php
+
+ Api
+ Block
+ Model
+ ViewModel
+ view/frontend/templates
+ view/adminhtml/templates
+ registration.php
+
+
+
diff --git a/phpstan.neon b/phpstan.neon
new file mode 100644
index 0000000..b2e4fda
--- /dev/null
+++ b/phpstan.neon
@@ -0,0 +1,11 @@
+includes:
+ - tests/quality/vendor/bitexpert/phpstan-magento/extension.neon
+
+parameters:
+ level: 8
+ paths:
+ - Api
+ - Block
+ - Model
+ - ViewModel
+ tmpDir: .test-results/phpstan
diff --git a/playwright.config.js b/playwright.config.js
new file mode 100644
index 0000000..2ffd4fe
--- /dev/null
+++ b/playwright.config.js
@@ -0,0 +1,19 @@
+const { defineConfig } = require("@playwright/test");
+
+module.exports = defineConfig({
+ testDir: "./tests/js",
+ testMatch: "**/*.spec.js",
+ globalSetup: require.resolve("./tests/js/render-footer-fixtures.js"),
+ timeout: 15000,
+ fullyParallel: true,
+ forbidOnly: Boolean(process.env.CI),
+ retries: process.env.CI ? 1 : 0,
+ failOnFlakyTests: Boolean(process.env.CI),
+ reporter: process.env.CI ? [["line"], ["html", { open: "never" }]] : "line",
+ outputDir: ".test-results/playwright",
+ use: {
+ browserName: "chromium",
+ headless: true,
+ trace: process.env.CI ? "retain-on-failure" : "off"
+ }
+});
diff --git a/playwright.integration.config.js b/playwright.integration.config.js
new file mode 100644
index 0000000..246982b
--- /dev/null
+++ b/playwright.integration.config.js
@@ -0,0 +1,19 @@
+const { defineConfig } = require("@playwright/test");
+
+module.exports = defineConfig({
+ testDir: "./tests/integration",
+ testMatch: "*.spec.js",
+ timeout: 30000,
+ fullyParallel: false,
+ workers: 1,
+ forbidOnly: true,
+ retries: 0,
+ reporter: "line",
+ outputDir: ".test-results/magento-integration",
+ use: {
+ browserName: "chromium",
+ headless: true,
+ serviceWorkers: "block",
+ ignoreHTTPSErrors: true
+ }
+});
diff --git a/registration.php b/registration.php
index aed60d5..386e28d 100644
--- a/registration.php
+++ b/registration.php
@@ -4,6 +4,6 @@
ComponentRegistrar::register(
ComponentRegistrar::MODULE,
- 'BasicRum_Analytics',
+ 'Basicrum_Analytics',
__DIR__
);
diff --git a/tests/integration/README.md b/tests/integration/README.md
new file mode 100644
index 0000000..6fc9d47
--- /dev/null
+++ b/tests/integration/README.md
@@ -0,0 +1,349 @@
+# Disposable Magento integration check
+
+The declared Phase 1 baseline is pinned in `baseline.env`: Magento Open Source
+2.4.7-p10 on PHP 8.3, with the listed Composer, MariaDB, and OpenSearch lines.
+Adobe's current system-requirements table lists PHP 8.2 and 8.3 for the 2.4.7
+release line. This is a baseline, not a claim that every patch or platform
+combination has been verified.
+
+## Magento-version/image-pinned local/CI stack
+
+`sh tests/integration/docker/start.sh` creates the dedicated Compose project
+`basicrum-release-baseline` using pinned image digests and the anonymous Mage-OS
+mirror. It provisions exactly 2.4.7-p10, installs the pinned Playwright runner,
+and leaves existing Magento stacks alone. Docker, Compose, OpenSSL and several
+GB of disk/RAM are required. Provisioning refuses to overwrite an installed
+application; reuse it with the commands below, not `start.sh` again.
+The full Magento Composer dependency closure is **not** locked in this repository.
+Fresh provisioning resolves transitive dependencies from the mirror; upstream
+releases/advisories can change that resolution or make installation fail. Image
+digests and the Magento version pin do not promise bit-for-bit reproducibility.
+
+The test browser runs inside the PHP container at `https://web:8443/`; the host
+binding is loopback-only port **9443**, never 443 or the existing local shop's
+8443. This is a test appliance, not a replacement developer shop. Database and
+search ports are not published. Its checked-in credentials are synthetic and
+must never be reused elsewhere. Admin 2FA, Admin analytics, Braintree extensions
+and outbound SMTP are disabled **only in this disposable installation**.
+`Magento_Paypal` stays enabled for the core CSP regression assertion. No browser
+allowlist exception is made for Braintree or any other external service.
+
+For subsequent runs after `down`, first recreate the containers with
+`docker compose -f tests/integration/docker/compose.yaml up -d`.
+Application/database volumes are retained; do not reprovision them. Git's
+`safe.directory` is baked into the PHP image and survives container recreation.
+After harness Dockerfile changes, run `docker compose -f tests/integration/docker/compose.yaml build php`
+before `up -d`. First-time `start.sh` installs npm dependencies using the caller's
+UID/GID so the bind-mounted checkout does not acquire root-owned `node_modules`.
+To refresh dependencies later, run `npm ci` as the host user, or use the same
+UID/GID-aware `exec` command from `start.sh`. Existing root-owned dependencies
+from older harness runs need their ownership repaired before this unprivileged install.
+
+From a clean committed checkout with the containers running:
+
+```sh
+docker compose -f tests/integration/docker/compose.yaml exec -T -w /module php sh tests/integration/build-artifact.sh
+docker compose -f tests/integration/docker/compose.yaml exec -T -w /module php php tests/integration/test-artifact.php
+docker compose -f tests/integration/docker/compose.yaml exec -T php sh /module/tests/integration/docker/install-artifact.sh
+docker compose -f tests/integration/docker/compose.yaml exec -T -w /module -e BASICRUM_RELEASE_TAG=0.1.0 php sh tests/integration/release-gate.sh
+docker compose -f tests/integration/docker/compose.yaml down
+```
+
+`down` stops/removes this stack's containers/network but retains its database
+and application volumes. It does not stop other projects or remove their data.
+The installer expands the verified Git-commit ZIP at `app/code/Basicrum/Analytics`;
+it tests the documented manual package installation, not a published Packagist
+release. Every stale extra file, including in development directories, causes
+identity verification to fail, not silent deletion.
+
+The release gate also installs the separate, test-only `Basicrum_CspTest`
+module from `tests/integration/fixtures/Basicrum/CspTest` before upgrade and DI
+compilation. Its `/basicrumcsptest/` page uses Magento's normal layout, production
+Basicrum footer and assets, and route-specific enforcing CSP. It is excluded
+from the production archive with the rest of `tests/`; never install it on a
+live store. It does not modify vendor code, headers, or production assets.
+The installer refreshes known fixture files but fails on stale extras; inspect
+those files and remove them deliberately before retrying. It never deletes them
+automatically or enables a fixture that failed the content comparison.
+
+The `Pinned native Magento` CI workflow runs the same provision/archive/install/
+gate sequence on pull requests and manual dispatch. A workflow definition alone
+is not evidence that a remote run passed. The default fixture does not import
+sample data or create orders: three sample routes and the opt-in checkout
+journey are explicitly skipped. See `docs/QUALITY-AND-RELEASE-READINESS.md` for
+the actual execution record and tested/untested matrix.
+
+Install this checkout as `app/code/Basicrum/Analytics` (module
+`Basicrum_Analytics`), enable it, and make its storefront reachable. For a
+standalone disposable installation, install the CSP fixture once (or refresh it
+after fixture changes), then run upgrade and DI compilation before the browser
+suite. The release gate above already performs these steps:
+
+```sh
+BASICRUM_DISPOSABLE_MAGENTO=1 \
+MAGENTO_ROOT=/absolute/path/to/disposable-magento \
+sh tests/integration/install-csp-fixture.sh
+/absolute/path/to/disposable-magento/bin/magento setup:upgrade
+/absolute/path/to/disposable-magento/bin/magento setup:di:compile
+```
+
+Then:
+
+```sh
+npm ci
+BASICRUM_DISPOSABLE_MAGENTO=1 \
+MAGENTO_ROOT=/absolute/path/to/disposable-magento \
+MAGENTO_STOREFRONT_URL=https://magento.test/ \
+tests/integration/configure-disposable.sh
+```
+
+Set `BASICRUM_DEPLOY_STATIC=1` when the installation uses production static
+content rather than developer-mode asset materialization. The script changes
+Basicrum settings and cleans Magento caches, so it refuses to run unless the
+explicit disposable-installation guard is present.
+It also requires the locally installed Playwright runner before changing settings;
+missing dependencies fail with an `npm ci` instruction, without downloading a
+different runner.
+
+The browser opens the rendered storefront, verifies the consent loader is in
+the page, confirms no Boomerang request, beacon, `RT`, or `BA` cookie occurs
+before allow, calls the public API twice, intercepts the local beacon, and
+asserts URL redaction plus `p_type`, `p_gen`, and `brum_site_id`. It then checks
+withdrawal cookie cleanup and requires an actual `X-Magento-Cache-Debug: HIT`
+on the subsequent navigation. It proves that the cached page waits for a fresh
+allow decision, stays cookie/beacon-silent beforehand, and then sends the same
+expected identity and redaction fields.
+It therefore covers the real layout, template, CSP path, static asset URL,
+cached HTML, and bundled Boomerang rather than a copied fixture.
+
+Every browser test first binds its URL to `MAGENTO_ROOT` using a random, exclusive,
+short-lived PHP challenge in that installation's `pub` directory. It checks the
+nonce and **web/FPM** PHP line, not only CLI PHP. Redirects, another document root,
+or a mismatched PHP line fail. The challenge is removed in `finally`, including
+after failures. It emits no configuration or secrets, and is never packaged
+with the module. The disposable Nginx configuration permits only that tightly
+named test path; a separately provisioned installation must configure the same
+test-only location. Never add that location to a live shop. Direct browser runs
+now also require `BASICRUM_DISPOSABLE_MAGENTO=1` and `MAGENTO_ROOT`.
+
+Basicrum script responses are hashed from the bytes actually returned to the
+browser and compared with the candidate's packaged loader/Boomerang files.
+Missing, unexpected or stale scripts fail; the primary storefront test requires
+both the consent wrapper and the real Boomerang response. Server-side source
+identity alone can no longer pass while stale deployed static content is served.
+Script merging or rewriting is not supported by this exact-byte baseline.
+
+An additional test establishes two independent anonymous browser contexts. The
+first visitor grants consent and receives a measurement cookie, then requests a
+unique URL that must be a cache MISS. The second visitor's first request to that
+URL must be a HIT with the same inline configuration, no Boomerang request, and
+no measurement cookie or beacon until its own allow callback. This exercises
+cache population by a request carrying measurement state, not only a page
+cached before consent. The callback itself still does not persist consent.
+The first visitor uses a second tab in its existing cookie jar for the MISS;
+this avoids conflating the cache test with navigation-time beacon interception.
+Both granted pages withdraw after the assertions. The network guard remains strict.
+
+Enable full-page caching and use a cacheable homepage. For built-in FPC, the
+disposable installation must be in developer mode so Magento emits its debug
+header. Magento's Varnish VCL also emits that header; any proxy in front must
+preserve it. Do not manufacture a `HIT` header in the web server. Missing headers,
+`MISS`, and `UNCACHEABLE` fail rather than skipping coverage. An extra pre-consent
+navigation warms the anonymous visitor's cookie/vary context. Browser routing
+disables the browser HTTP cache, so it cannot substitute for server FPC here.
+This check covers the selected store/homepage, not cross-store cache isolation,
+automatic invalidation after settings changes (CR-D-002), or a Varnish matrix.
+
+The CSP assertions require the baseline's enabled `Magento_Paypal` module:
+`www.paypal.com` from its `csp_whitelist.xml` must survive alongside core
+`'self'` and the Basicrum collector in storefront fetch directives. A fresh
+empty-cart `checkout/` request is inspected without following its redirect,
+so the check requires an actual enforcing `Content-Security-Policy` header,
+not the cart page's report-only header. This check creates no order.
+
+The mandatory `enforcing-csp.spec.js` tests render the isolated fixture page
+with a real enforcing header and `unsafe-inline` absent from `script-src`.
+They require the production inline bootstrap's DOM nonce to match that header,
+same-origin loader/Boomerang requests with verified bytes, pre-consent silence,
+single loading after repeated allow, intercepted beacon identity, withdrawal
+cookie cleanup, and no CSP violations or page errors. Two server-rendered visits
+must receive different matching nonces. A separate negative control appends an
+unnonced inline marker script: the browser must block it and report an enforcing
+violation. Test instrumentation only observes events and invokes the public
+consent callbacks outside this explicit negative control; it never rewrites
+headers, nonces, or production scripts.
+
+The fixture page is deliberately non-cacheable, like checkout; its response
+must be `no-store`, not an FPC HIT. This verifies enforcing-CSP execution and
+per-render nonce handling, not nonce behavior on cacheable pages. The existing
+report-only homepage cache-HIT/visitor-isolation checks remain unchanged. A
+cacheable enforcing-CSP deployment, custom nonce-only/strict-dynamic policies,
+and CDN/Varnish nonce handling require separate verification. Missing fixture
+installation fails the native tests rather than silently skipping them.
+
+Admin checks require at least one website/store view with single-store mode
+off and permission to view all three scopes. They verify that the collector
+origin is absent from Admin CSP, that the three display-only rows have no
+inheritance controls/scope labels, and that real settings retain those controls.
+Use a dedicated collector origin that no other module whitelists in Admin;
+otherwise origin absence cannot isolate Basicrum's contribution. Scope
+switching is read-only: the test never saves configuration.
+
+## Network isolation
+
+All browser contexts, including popups, intercept the configured beacon origin
+and path and return a local response. Other direct requests are allowed only
+to `MAGENTO_STOREFRONT_URL` and `MAGENTO_ADMIN_URL` origins; unexpected beacon
+identity payloads are blocked even at another path on those origins. Unexpected
+requests fail the test, with URLs logged without query strings or POST bodies.
+Service workers and WebSockets are blocked.
+
+A loopback test proxy permits only the storefront/Admin host and port pairs,
+never the collector. This also stops external destinations reached through
+redirect chains, which Playwright's route handler alone does not re-intercept.
+Use dedicated disposable origins and same-origin assets. Disable external
+payment/analytics scripts in that installation; do not whitelist their hosts
+to make a failed test pass. This is browser-request isolation, not an OS-level
+firewall or a sandbox for Magento's server-side integrations. Keep outbound
+email and other server-side services isolated separately.
+
+## Native configuration-save checks
+
+With this checkout installed and native DI current, run:
+
+```sh
+BASICRUM_DISPOSABLE_MAGENTO=1 \
+MAGENTO_ROOT=/absolute/path/to/disposable-magento \
+php tests/integration/config-save.php
+```
+
+This boots Magento's Admin area and uses the actual `Magento\Config\Model\Config`
+save model, not the lightweight PHP doubles. It checks defaults, endpoint/UUID
+validation, HTTP normalization and same-form changes, bounded waits, website/store
+overrides and re-inheritance, deliberate immediate consent, and fail-closed
+runtime behavior after invalid imports. It does not automate submission of the
+Admin HTML form; the browser suite separately covers its rendering.
+
+Each case runs within an outer database transaction and rolls back all Basicrum
+fixture rows, including after validation exceptions. Configuration caching is
+disabled only in that PHP process so fixture values cannot enter shared config
+cache. Original rows are compared after rollback. Use disposable data only:
+native save events can invalidate caches or trigger third-party observers, and
+those non-database side effects are not rolled back. No order is created.
+
+## Page-type alignment checks
+
+The suite also checks Magento 1-aligned labels in real beacons from public
+Magento 2 pages. It covers native route differences, HTTP 404, explicit
+unmapped fallback, and redirect destinations: a logged-out account is `Login`,
+and checkout/success without a valid cart/order session is `Cart`.
+
+Set `MAGENTO_SAMPLE_DATA=1` to include the Luma sample CMS page `about-us`,
+product `fusion-backpack.html`, and category `gear/bags.html`. These are skipped
+without that flag; all require installed, indexed, active sample data.
+
+To check an already configured disposable store without rewriting settings,
+run Playwright directly. `MAGENTO_BEACON_URL` and `MAGENTO_SITE_ID` can override
+the default test endpoint and identity; they must match the effective store
+configuration. Consent-controlled loading and query redaction must be enabled.
+The browser intercepts the configured endpoint and returns a local response;
+it does not forward beacons to that collector.
+
+```sh
+MAGENTO_STOREFRONT_URL=https://magento.test/ \
+MAGENTO_ROOT=/absolute/path/to/disposable-magento \
+BASICRUM_DISPOSABLE_MAGENTO=1 \
+MAGENTO_BEACON_URL=https://collector.basicrum.test/beacon \
+MAGENTO_SITE_ID=550e8400-e29b-41d4-a716-446655440000 \
+MAGENTO_SAMPLE_DATA=1 \
+npx --no-install playwright test --config=playwright.integration.config.js
+```
+
+`checkout-page-types.spec.js` is separately guarded. In addition to the URL
+and identity settings, it requires **both** `BASICRUM_DISPOSABLE_MAGENTO=1`
+and `MAGENTO_TEST_CHECKOUT=1`. It assumes Luma, the in-stock Fusion Backpack
+sample product, guest checkout, US/California shipping, Flat Rate shipping,
+and the offline Check / Money order payment method. It creates one test order
+per successful run (and a quote on unsuccessful runs), using synthetic
+`example.test` contact details. Use only on disposable data with outbound mail
+captured locally; the fixture does not delete orders or alter store settings.
+No live payment provider is selected. It verifies actual `Checkout` and
+`Checkout Success` beacons, not a manually substituted browser variable.
+
+Authenticated account/address/order/wishlist and PayPal agreement journeys
+are not included in this public browser matrix. Their exact labels, alongside
+all other mappings, are covered by the focused PHP tests. A missing journey
+fixture is not evidence of end-to-end verification for that page.
+
+## Required pre-release native gate
+
+The `0.0.2` identity must not be reused for this breaking, previously
+unreleased technical-namespace change. The Phase 1 release candidate is
+`0.1.0`, and the guarded release script accepts only `0.1.0` (with an optional
+`v` tag prefix). Change that explicit policy in review if the intended release
+number changes; do not bypass the gate or add a Composer `version` field.
+
+Before creating the tag, first require the fast CI jobs to pass, then run the
+following against the pinned disposable Magento baseline:
+
+```sh
+BASICRUM_DISPOSABLE_MAGENTO=1 \
+BASICRUM_RELEASE_TAG=0.1.0 \
+MAGENTO_ROOT=/absolute/path/to/disposable-magento \
+MAGENTO_STOREFRONT_URL=https://magento.test/ \
+MAGENTO_ADMIN_URL=https://magento.test/admin/ \
+MAGENTO_ADMIN_USERNAME=basicrum-release-check \
+MAGENTO_ADMIN_PASSWORD='disposable-secret' \
+tests/integration/release-gate.sh
+```
+
+Use the installation's real secret Admin login path in `MAGENTO_ADMIN_URL`.
+The browser follows Magento's own Stores > Configuration navigation so it does
+not bypass Admin secret-key URLs. The disposable Admin account must be able to
+open that page, and login challenges such as two-factor authentication or
+CAPTCHA must be disabled for this isolated test account.
+
+The gate requires Git and a clean, committed module checkout, including no
+untracked files. First build the Git-commit ZIP using `build-artifact.sh` and
+install its contents. `BASICRUM_ARTIFACT` can select a ZIP path; the default is
+`.test-results/package/basicrum-analytics.zip`. The gate hashes every ZIP entry
+against the candidate's production manifest. Before any upgrade/configuration
+write it also resolves `Basicrum_Analytics` through
+Magento's actual `ComponentRegistrar` and compares SHA-256 hashes of package
+files (including PHP, XML, templates, assets, notices, and top-level metadata).
+Missing, modified, and any extra installed files fail, including ignored/development
+files. This strict distribution gate is not for a full checkout or checkout symlink;
+the separate `configure-disposable.sh` development runner still supports those.
+The expected manifest hashes committed Git blobs, not working-tree files.
+Exclusions come from the committed `composer.json` archive boundary, never installed
+metadata; `.gitattributes` applies the same boundary to the Git ZIP. Ignored or
+untracked local files cannot become expected archive entries. Tests, development
+tools/configuration, CI and generated output are not shipped. PHP, XML, templates,
+assets, README, changelog and third-party notices remain verified. Internal source
+symlinks are rejected; the module directory itself may be a symlink.
+
+The gate then compares the installed Magento
+Open Source patch exactly and the PHP, Composer, MariaDB, and OpenSearch
+major/minor lines with `baseline.env`. Missing versions, a different edition,
+or a mismatch fail the gate; there is no bypass flag. Supplemental tests on
+another platform do not certify this release baseline.
+
+The gate then runs `setup:upgrade`, dependency-injection compilation, an English
+static-content deployment, and the native configuration-save tests before
+applying the browser test configuration. Playwright
+then exercises the rendered storefront and intercepted beacon, reloads a warm
+full-page-cache response, logs into Magento Admin, and verifies that the
+Basicrum logo and required configuration/status fields render. Any command,
+login, rendering assertion, or browser check failure blocks the tag.
+Afterward it rechecks the clean checkout/commit, installed files, and ZIP identity.
+The final success line records the candidate SHA and intended tag; retain the
+command output as release evidence and tag only that exact commit. Do not edit
+or resync either tree during the gate. These checks tie source evidence to the
+candidate and to the tested distribution bytes. The success line certifies only
+the native technical checks, not legal approval, a published Composer install,
+untested themes, or optional skipped journeys. The rights-holder-approved module
+LICENSE remains a separate release requirement.
+
+Fast CI retains native browser test discovery (`--list`); the separate native
+workflow provisions and executes Magento. Neither discovery nor merely adding
+a workflow is evidence of a passing native run.
diff --git a/tests/integration/admin.spec.js b/tests/integration/admin.spec.js
new file mode 100644
index 0000000..fbd5cd5
--- /dev/null
+++ b/tests/integration/admin.spec.js
@@ -0,0 +1,93 @@
+const { test, expect } = require("./fixtures");
+const { expectAdminCsp } = require("./csp");
+
+const adminUrl = process.env.MAGENTO_ADMIN_URL;
+const adminUsername = process.env.MAGENTO_ADMIN_USERNAME;
+const adminPassword = process.env.MAGENTO_ADMIN_PASSWORD;
+
+test.skip(
+ !adminUrl || !adminUsername || !adminPassword,
+ "MAGENTO_ADMIN_URL and disposable Admin credentials are required"
+);
+
+test("Basicrum configuration renders in Magento Admin", async ({ page }) => {
+ test.setTimeout(60000);
+ await page.goto(adminUrl, { waitUntil: "domcontentloaded" });
+
+ const username = page.locator("#username");
+ if (await username.isVisible()) {
+ await username.fill(adminUsername);
+ await page.locator("#login").fill(adminPassword);
+ await page.locator("button.action-login").click();
+ await expect(username).toHaveCount(0);
+ }
+
+ const adminUsageDeny = page.locator(
+ ".admin-usage-notification .action-secondary"
+ );
+ if (await adminUsageDeny.isVisible()) {
+ await adminUsageDeny.click();
+ }
+
+ const configurationMenu = page.locator('[data-ui-id="menu-magento-config-system-config"] > a');
+ const configurationUrl = await configurationMenu.getAttribute("href");
+ expect(configurationUrl).toBeTruthy();
+ await page.goto(configurationUrl, { waitUntil: "domcontentloaded" });
+
+ const basicrumSection = page
+ .locator("#system_config_tabs a")
+ .filter({ hasText: "Basicrum Analytics" });
+ const basicrumUrl = await basicrumSection.getAttribute("href");
+ expect(basicrumUrl).toBeTruthy();
+ const response = await page.goto(basicrumUrl, { waitUntil: "networkidle" });
+ expectAdminCsp(response.headers());
+
+ const generalSettings = page.locator('a[href="#basicrum_general-link"]');
+ const logo = page.locator(".basicrum-config-logo");
+ await expect
+ .poll(async () => {
+ if (!(await logo.isVisible())) {
+ await generalSettings.click();
+ }
+ return logo.isVisible();
+ })
+ .toBe(true);
+ await expect(page.getByText("Monitoring Status", { exact: true })).toBeVisible();
+ await expect(page.getByText("Beacon Endpoint", { exact: true })).toBeVisible();
+ await expect(page.getByText("Brum Site ID", { exact: true })).toBeVisible();
+ await expect(page.locator("#basicrum_consent_mode")).toHaveCount(0);
+
+ const displayRows = [
+ "basicrum_general_monitoring_status",
+ "basicrum_general_boomerang_version",
+ "basicrum_consent_integration_help"
+ ];
+ const expectDisplayOnlyRows = async () => {
+ for (const id of displayRows) {
+ const row = page.locator("#row_" + id);
+ await expect(row).toHaveCount(1);
+ await expect(row.locator('input, select, textarea, [data-config-scope]')).toHaveCount(0);
+ }
+ };
+ await expectDisplayOnlyRows();
+
+ // Exercise Magento's own switcher and secret-key URLs, without saving.
+ for (const scope of ["website", "store-view"]) {
+ await page.locator("#store-change-button").click();
+ await page.locator('.store-switcher-' + scope + ' a[data-value]').first().click();
+ const confirmation = page.locator(".modal-popup.confirm .action-accept");
+ await expect(confirmation).toBeVisible();
+ const navigation = page.waitForNavigation({ waitUntil: "networkidle" });
+ await confirmation.click();
+ const scopedResponse = await navigation;
+ expect(scopedResponse).toBeTruthy();
+ expectAdminCsp(scopedResponse.headers());
+ await expectDisplayOnlyRows();
+ // Real settings still inherit at both scopes; removing all checkboxes is
+ // not an acceptable way to fix the display-only rows.
+ for (const id of ["basicrum_general_beacon_endpoint", "basicrum_general_brum_site_id",
+ "basicrum_consent_enabled", "basicrum_privacy_strip_query_string"]) {
+ await expect(page.locator("#" + id + "_inherit")).toHaveCount(1);
+ }
+ }
+});
diff --git a/tests/integration/baseline.env b/tests/integration/baseline.env
new file mode 100644
index 0000000..0589e74
--- /dev/null
+++ b/tests/integration/baseline.env
@@ -0,0 +1,6 @@
+# Declared Phase 1 disposable-store integration baseline.
+MAGENTO_VERSION=2.4.7-p10
+PHP_VERSION=8.3
+COMPOSER_VERSION=2.10
+MARIADB_VERSION=10.11
+OPENSEARCH_VERSION=2.19
diff --git a/tests/integration/baseline.php b/tests/integration/baseline.php
new file mode 100644
index 0000000..d0b59fd
--- /dev/null
+++ b/tests/integration/baseline.php
@@ -0,0 +1,19 @@
+ new URL(url).origin));
+ await context.route("**/*", async route => {
+ const request = route.request();
+ const url = new URL(request.url());
+ if (url.origin === endpoint.origin && url.pathname === endpoint.pathname) {
+ beacons.push(route.request());
+ return route.fulfill({
+ status: 204,
+ headers: { "access-control-allow-origin": "*" },
+ body: ""
+ });
+ }
+
+ const parameters = requestParameters(request);
+ const measurement = parameters.has("brum_site_id") || parameters.get("p_gen") === "mage2";
+ if (localOrigins.has(url.origin) && !measurement) {
+ return route.continue();
+ }
+
+ // Do not print query strings or POST bodies in failure diagnostics.
+ blocked.push(url.origin + url.pathname);
+ await route.abort("blockedbyclient");
+ });
+ // Boomerang uses HTTP, but do not let another script open an unguarded socket.
+ await context.routeWebSocket("**/*", socket => {
+ const url = new URL(socket.url());
+ blocked.push(url.origin + url.pathname);
+ socket.close();
+ });
+ return { beacons, blocked };
+}
+
+module.exports = { beaconUrl, guardNetwork, requestParameters, siteId };
diff --git a/tests/integration/bootstrap.php b/tests/integration/bootstrap.php
new file mode 100644
index 0000000..a3ad88b
--- /dev/null
+++ b/tests/integration/bootstrap.php
@@ -0,0 +1,18 @@
+getObjectManager();
+$objectManager->get(State::class)->setAreaCode(Area::AREA_ADMINHTML);
+$objectManager->configure($objectManager->get(ConfigLoader::class)->load(Area::AREA_ADMINHTML));
diff --git a/tests/integration/build-artifact.sh b/tests/integration/build-artifact.sh
new file mode 100644
index 0000000..ab5acf5
--- /dev/null
+++ b/tests/integration/build-artifact.sh
@@ -0,0 +1,8 @@
+#!/usr/bin/env sh
+set -eu
+module_root=$(CDPATH= cd -- "$(dirname -- "$0")/../.." && pwd)
+cd "$module_root"
+mkdir -p .test-results/package
+candidate_commit=$(sh tests/integration/check-candidate.sh)
+git archive --format=zip --output=.test-results/package/basicrum-analytics.zip "$candidate_commit"
+php tests/integration/check-artifact.php .test-results/package/basicrum-analytics.zip
diff --git a/tests/integration/cache.js b/tests/integration/cache.js
new file mode 100644
index 0000000..81fef14
--- /dev/null
+++ b/tests/integration/cache.js
@@ -0,0 +1,11 @@
+const { expect } = require("@playwright/test");
+
+function expectFullPageCacheHit(response) {
+ expect(response.status(), "Cached storefront navigation must succeed").toBe(200);
+ expect(response.headers()["x-magento-cache-debug"],
+ "A real full-page-cache HIT is required. Enable FPC and expose X-Magento-Cache-Debug " +
+ "(developer mode for built-in FPC, or Magento's Varnish VCL); do not synthesize this header."
+ ).toBe("HIT");
+}
+
+module.exports = { expectFullPageCacheHit };
diff --git a/tests/integration/candidate-files.php b/tests/integration/candidate-files.php
new file mode 100644
index 0000000..42c9f4e
--- /dev/null
+++ b/tests/integration/candidate-files.php
@@ -0,0 +1,79 @@
+ ['pipe', 'w'], 2 => ['pipe', 'w']], $pipes);
+ if (!is_resource($process)) {
+ throw new RuntimeException('Unable to inspect the candidate commit.');
+ }
+ $output = stream_get_contents($pipes[1]);
+ $error = stream_get_contents($pipes[2]);
+ fclose($pipes[1]);
+ fclose($pipes[2]);
+ if (proc_close($process) !== 0) {
+ throw new RuntimeException('Unable to inspect the candidate commit: ' . trim($error));
+ }
+ return $output;
+}
+
+/** Hash only committed production blobs. Ignored or untracked disk files cannot become expected files. */
+function basicrum_candidate_files(string $root): array
+{
+ $commit = trim(basicrum_candidate_git($root, ['rev-parse', '--verify', 'HEAD']));
+ $metadata = json_decode(basicrum_candidate_git($root, ['show', $commit . ':composer.json']), true, 512, JSON_THROW_ON_ERROR);
+ $excluded = array_map(
+ static fn (string $path): string => ltrim($path, '/'),
+ $metadata['archive']['exclude']
+ );
+ $files = [];
+ foreach (explode("\0", rtrim(basicrum_candidate_git($root, ['ls-tree', '-rz', $commit]), "\0")) as $entry) {
+ [$object, $path] = explode("\t", $entry, 2);
+ if (in_array(explode('/', $path)[0], $excluded, true)) {
+ continue;
+ }
+ [$mode, $type, $id] = explode(' ', $object);
+ if ($type !== 'blob' || !in_array($mode, ['100644', '100755'], true)) {
+ throw new RuntimeException('Unsupported candidate file: ' . $path);
+ }
+ $files[$path] = hash('sha256', basicrum_candidate_git($root, ['cat-file', 'blob', $id]));
+ }
+ if (!isset($files['registration.php'], $files['composer.json'])) {
+ throw new RuntimeException('The candidate commit must contain registration.php and composer.json.');
+ }
+ ksort($files);
+ return $files;
+}
+
+/** A distribution installation has no development-file exceptions, even for hidden files. */
+function basicrum_installed_files(string $root): array
+{
+ $files = [];
+ $directory = new RecursiveDirectoryIterator($root, FilesystemIterator::SKIP_DOTS);
+ foreach (new RecursiveIteratorIterator($directory) as $file) {
+ $relative = substr($file->getPathname(), strlen($root) + 1);
+ // The module root may be a symlink; links inside the package are never supported.
+ if ($file->isLink() || !$file->isFile()) {
+ throw new RuntimeException('Unsupported candidate file: ' . $relative);
+ }
+ $hash = hash_file('sha256', $file->getPathname());
+ if ($hash === false) {
+ throw new RuntimeException('Unable to hash candidate file: ' . $relative);
+ }
+ $files[$relative] = $hash;
+ }
+ ksort($files);
+ return $files;
+}
+
+function basicrum_assert_installed_candidate(string $candidateRoot, string $installedRoot): void
+{
+ $candidate = basicrum_candidate_files($candidateRoot);
+ $installed = basicrum_installed_files($installedRoot);
+ foreach (array_unique(array_merge(array_keys($candidate), array_keys($installed))) as $file) {
+ if (($candidate[$file] ?? null) !== ($installed[$file] ?? null)) {
+ throw new RuntimeException('Installed Basicrum module differs from the candidate: ' . $file);
+ }
+ }
+}
diff --git a/tests/integration/check-artifact.php b/tests/integration/check-artifact.php
new file mode 100644
index 0000000..bd00ea6
--- /dev/null
+++ b/tests/integration/check-artifact.php
@@ -0,0 +1,29 @@
+open($path) !== true) {
+ throw new RuntimeException('Pass the committed distribution ZIP to verify.');
+}
+$actual = [];
+for ($index = 0; $index < $zip->numFiles; $index++) {
+ $name = $zip->getNameIndex($index);
+ if (str_ends_with($name, '/')) {
+ continue;
+ }
+ if (isset($actual[$name])) {
+ throw new RuntimeException('Duplicate archive entry: ' . $name);
+ }
+ $actual[$name] = hash('sha256', $zip->getFromIndex($index));
+}
+$zip->close();
+$expected = basicrum_candidate_files(dirname(__DIR__, 2));
+ksort($actual);
+if ($actual !== $expected) {
+ throw new RuntimeException('Distribution contents differ from the candidate production manifest.');
+}
+echo 'PASS: distribution ZIP contains exactly ' . count($actual) . ' candidate production files; SHA-256 '
+ . hash_file('sha256', $path) . PHP_EOL;
diff --git a/tests/integration/check-baseline.php b/tests/integration/check-baseline.php
new file mode 100644
index 0000000..5a6e607
--- /dev/null
+++ b/tests/integration/check-baseline.php
@@ -0,0 +1,55 @@
+get(ProductMetadataInterface::class);
+$actual = ['MAGENTO_VERSION' => $metadata->getVersion(), 'PHP_VERSION' => PHP_VERSION];
+if ($metadata->getEdition() !== 'Community') {
+ throw new RuntimeException('The declared baseline is Magento Open Source (Community edition).');
+}
+// Refuse incompatible core runtimes before connecting to optional services.
+foreach (basicrum_baseline_errors($expected, $actual) as $key => $error) {
+ if (isset($actual[$key])) {
+ throw new RuntimeException($error);
+ }
+}
+
+$pipes = [];
+$process = proc_open(['composer', '--no-ansi', '--version'], [1 => ['pipe', 'w'], 2 => ['pipe', 'w']], $pipes);
+if (!is_resource($process)) {
+ throw new RuntimeException('Composer is required on PATH.');
+}
+$composerOutput = stream_get_contents($pipes[1]);
+stream_get_contents($pipes[2]);
+fclose($pipes[1]);
+fclose($pipes[2]);
+if (proc_close($process) !== 0 || !preg_match('/Composer version (\d+\.\d+\.\d+)/', $composerOutput, $matches)) {
+ throw new RuntimeException('Unable to identify the installed Composer version.');
+}
+$actual['COMPOSER_VERSION'] = $matches[1];
+$databaseVersion = (string) $objectManager->get(ResourceConnection::class)->getConnection()->fetchOne('SELECT VERSION()');
+if (stripos($databaseVersion, 'MariaDB') === false) {
+ throw new RuntimeException('The declared baseline requires MariaDB.');
+}
+$actual['MARIADB_VERSION'] = $databaseVersion;
+$resolver = $objectManager->get(ClientResolver::class);
+if ($resolver->getCurrentEngine() !== 'opensearch') {
+ throw new RuntimeException('The declared baseline requires the configured OpenSearch engine.');
+}
+$info = $resolver->create()->getOpenSearchClient()->info();
+if (($info['version']['distribution'] ?? '') !== 'opensearch') {
+ throw new RuntimeException('The configured search server did not identify itself as OpenSearch.');
+}
+$actual['OPENSEARCH_VERSION'] = $info['version']['number'] ?? '';
+$errors = basicrum_baseline_errors($expected, $actual);
+if ($errors !== []) {
+ throw new RuntimeException(implode("\n", $errors));
+}
+echo 'PASS: installed Magento/PHP/Composer/MariaDB/OpenSearch match baseline.env.' . PHP_EOL;
diff --git a/tests/integration/check-candidate.sh b/tests/integration/check-candidate.sh
new file mode 100644
index 0000000..4977fc9
--- /dev/null
+++ b/tests/integration/check-candidate.sh
@@ -0,0 +1,15 @@
+#!/usr/bin/env sh
+set -eu
+
+module_root=$(CDPATH= cd -- "$(dirname -- "$0")/../.." && pwd -P)
+repository_root=$(git -C "$module_root" rev-parse --show-toplevel)
+if [ "$repository_root" != "$module_root" ]; then
+ echo "Run the release gate from the module's own Git checkout." >&2
+ exit 1
+fi
+changes=$(git -C "$module_root" status --porcelain --untracked-files=all)
+if [ -n "$changes" ]; then
+ echo "The release candidate must be clean: commit or remove pending changes before certification." >&2
+ exit 1
+fi
+git -C "$module_root" rev-parse --verify HEAD
diff --git a/tests/integration/check-installed-candidate.php b/tests/integration/check-installed-candidate.php
new file mode 100644
index 0000000..0dc68e8
--- /dev/null
+++ b/tests/integration/check-installed-candidate.php
@@ -0,0 +1,18 @@
+getPath(ComponentRegistrar::MODULE, 'Basicrum_Analytics');
+if (!$installedRoot || !is_dir($installedRoot)) {
+ throw new RuntimeException('Basicrum_Analytics is not registered in this Magento installation.');
+}
+basicrum_assert_installed_candidate(dirname(__DIR__, 2), realpath($installedRoot));
+echo 'PASS: registered Basicrum installation exactly matches the committed production files.' . PHP_EOL;
diff --git a/tests/integration/checkout-page-types.spec.js b/tests/integration/checkout-page-types.spec.js
new file mode 100644
index 0000000..d93ba91
--- /dev/null
+++ b/tests/integration/checkout-page-types.spec.js
@@ -0,0 +1,55 @@
+const { test, expect } = require("./fixtures");
+const { requestParameters, siteId } = require("./beacons");
+
+const storefrontUrl = process.env.MAGENTO_STOREFRONT_URL;
+test.skip(
+ !storefrontUrl || process.env.BASICRUM_DISPOSABLE_MAGENTO !== "1" || process.env.MAGENTO_TEST_CHECKOUT !== "1",
+ "Explicit disposable-store and offline checkout opt-ins are required; this test creates an order"
+);
+
+test("a real offline Luma checkout emits Checkout then Checkout Success", async ({ page, beaconTraffic }) => {
+ test.setTimeout(90000);
+ const { beacons } = beaconTraffic;
+
+ async function expectPageBeacon(label) {
+ await page.waitForFunction(() => typeof window.OPT_IN_BASICRUM_LOADER_WRAPPER === "function");
+ const before = beacons.length;
+ await page.evaluate(() => window.OPT_IN_BASICRUM_LOADER_WRAPPER());
+ await expect.poll(() => beacons.slice(before).some((request) => requestParameters(request).get("p_type") === label), {
+ timeout: 15000
+ }).toBe(true);
+ const parameters = requestParameters(beacons.slice(before).find((request) => requestParameters(request).get("p_type") === label));
+ expect(parameters.get("p_gen")).toBe("mage2");
+ expect(parameters.get("brum_site_id")).toBe(siteId);
+ }
+
+ await page.goto(new URL("fusion-backpack.html", storefrontUrl).toString());
+ await page.getByRole("button", { name: "Add to Cart", exact: true }).click();
+ await expect(page.locator(".message-success")).toContainText("Fusion Backpack");
+ await page.goto(new URL("checkout/", storefrontUrl).toString());
+ await expect(page.locator("#customer-email")).toBeVisible();
+ await expectPageBeacon("Checkout");
+
+ await page.locator("#customer-email").fill("basicrum-page-types@example.test");
+ await page.locator('input[name="firstname"]').fill("Basicrum");
+ await page.locator('input[name="lastname"]').fill("Test");
+ await page.locator('input[name="street[0]"]').fill("123 Test Street");
+ await page.locator('input[name="city"]').fill("Los Angeles");
+ await page.locator('select[name="country_id"]').selectOption("US");
+ await page.locator('select[name="region_id"]').selectOption({ label: "California" });
+ await page.locator('input[name="postcode"]').fill("90001");
+ await page.locator('input[name="telephone"]').fill("5550100100");
+ await page.locator('input[value="flatrate_flatrate"]').check();
+ await page.getByRole("button", { name: "Next", exact: true }).click();
+
+ // Never choose a live payment provider. The fixture requires Check / Money order.
+ const offlinePayment = page.locator(".payment-method").filter({ has: page.locator("#checkmo") });
+ await expect(offlinePayment).toBeVisible();
+ if (await page.locator("#checkmo").isVisible()) {
+ await page.locator("#checkmo").check();
+ }
+ await expect(offlinePayment).toHaveClass(/_active/);
+ await offlinePayment.getByRole("button", { name: "Place Order", exact: true }).click();
+ await page.waitForURL(/\/checkout\/onepage\/success\//);
+ await expectPageBeacon("Checkout Success");
+});
diff --git a/tests/integration/config-save.php b/tests/integration/config-save.php
new file mode 100644
index 0000000..233d243
--- /dev/null
+++ b/tests/integration/config-save.php
@@ -0,0 +1,196 @@
+get(ResourceConnection::class);
+$connection = $resource->getConnection();
+$table = $resource->getTableName('core_config_data');
+$scopeConfig = $objectManager->get(ReinitableConfigInterface::class);
+$cacheState = $objectManager->get(StateInterface::class);
+$factory = $objectManager->get(ConfigFactory::class);
+$config = $objectManager->get(Config::class);
+$stores = $objectManager->get(StoreManagerInterface::class)->getStores();
+$store = reset($stores);
+if (!$store || $connection->getTransactionLevel() !== 0) {
+ throw new RuntimeException('An installed store and an idle database connection are required.');
+}
+$storeId = (int) $store->getId();
+$websiteId = (int) $store->getWebsiteId();
+$siteId = '550e8400-e29b-41d4-a716-446655440000';
+$websiteSiteId = '123e4567-e89b-42d3-a456-426614174000';
+$snapshot = static fn (): array => $connection->fetchAll(
+ $connection->select()->from($table)->where('path LIKE ?', 'basicrum/%')->order('config_id')
+);
+$original = $snapshot();
+$cacheEnabled = $cacheState->isEnabled('config');
+// Per-process only; never persist this flag. Uncommitted fixture values must
+// neither read from nor be published into Magento's shared configuration cache.
+$cacheState->setEnabled('config', false);
+
+$assertSame = static function ($expected, $actual, string $message): void {
+ if ($expected !== $actual) {
+ throw new RuntimeException($message);
+ }
+};
+$save = static function (array $fields, array $scope = []) use ($factory): void {
+ $groups = [];
+ foreach ($fields as $path => $value) {
+ [$group, $field] = explode('/', $path);
+ $groups[$group]['fields'][$field] = $value === null ? ['inherit' => '1'] : ['value' => $value];
+ }
+ // This is the same model/data shape used by the native Admin save action.
+ $factory->create(['data' => array_merge(['section' => 'basicrum', 'groups' => $groups], $scope)])->save();
+};
+$seed = static function () use ($save, $siteId): void {
+ $save([
+ 'general/enabled' => '1',
+ 'general/beacon_endpoint' => 'https://default.example.test/beacon',
+ 'general/brum_site_id' => $siteId,
+ ]);
+};
+$raw = static function (string $path, string $scope = 'default', int $id = 0) use ($connection, $table) {
+ return $connection->fetchOne($connection->select()->from($table, 'value')
+ ->where('path = ?', 'basicrum/' . $path)->where('scope = ?', $scope)->where('scope_id = ?', $id));
+};
+$expectInvalid = static function (array $fields) use ($save): void {
+ try {
+ $save($fields);
+ } catch (LocalizedException $exception) {
+ return;
+ }
+ throw new RuntimeException('Native configuration save accepted invalid input.');
+};
+
+$tests = [];
+$tests['native XML defaults and environment-specific HTTP policy'] = static function () use ($scopeConfig, $config, $assertSame, $objectManager): void {
+ $assertSame('0', (string) $scopeConfig->getValue(Config::XML_PATH_ENABLED, 'default'), 'enabled default');
+ $assertSame('1', (string) $scopeConfig->getValue(Config::XML_PATH_CONSENT_ENABLED, 'default'), 'consent default');
+ $assertSame(null, $config->getRuntimeConfig(), 'fresh native runtime gate');
+ $pool = $objectManager->get(TypePool::class);
+ $assertSame(true, $pool->isPresent(Config::XML_PATH_DEVELOPMENT_MODE, TypePool::TYPE_ENVIRONMENT), 'HTTP exception belongs to this environment');
+ $assertSame(false, $pool->isPresent(Config::XML_PATH_CONSENT_ENABLED, TypePool::TYPE_ENVIRONMENT), 'consent export classification unchanged');
+};
+$tests['native backend normalizes HTTPS and bounds the wait'] = static function () use ($save, $siteId, $raw, $config, $assertSame): void {
+ $save([
+ 'general/enabled' => '1', 'general/brum_site_id' => $siteId,
+ 'general/beacon_endpoint' => ' http://collector.example.test/beacon ',
+ 'performance/wait_after_onload' => '1', 'performance/delay_ms' => '45000',
+ ]);
+ $assertSame('https://collector.example.test/beacon', $raw('general/beacon_endpoint'), 'saved HTTPS policy');
+ $assertSame(30000, $config->getRuntimeConfig()['delay_ms'], 'saved and effective bounded wait');
+};
+$tests['native save rejects invalid endpoint without replacing its value'] = static function () use ($seed, $expectInvalid, $raw, $assertSame): void {
+ $seed();
+ $expectInvalid(['general/beacon_endpoint' => 'not-a-url']);
+ $assertSame('https://default.example.test/beacon', $raw('general/beacon_endpoint'), 'invalid endpoint must not persist');
+};
+$tests['native save rejects invalid site identity without replacing its value'] = static function () use ($seed, $expectInvalid, $raw, $siteId, $assertSame): void {
+ $seed();
+ $expectInvalid(['general/brum_site_id' => 'not-a-uuid']);
+ $assertSame($siteId, $raw('general/brum_site_id'), 'invalid identity must not persist');
+};
+$tests['same-form HTTP exception applies before its old saved value'] = static function () use ($seed, $save, $raw, $config, $assertSame): void {
+ $seed();
+ $save(['general/beacon_endpoint' => 'http://dev.example.test/beacon', 'developer/development_mode' => '1']);
+ $assertSame('http://dev.example.test/beacon', $raw('general/beacon_endpoint'), 'same-form allow');
+ $assertSame('http://dev.example.test/beacon', $config->getRuntimeConfig()['beacon_endpoint'], 'effective allow');
+ $save(['general/beacon_endpoint' => 'http://dev.example.test/beacon', 'developer/development_mode' => '0']);
+ $assertSame('https://dev.example.test/beacon', $raw('general/beacon_endpoint'), 'same-form disallow');
+};
+$tests['store HTTP inheritance and identity resolve through the native website'] = static function () use (
+ $seed, $save, $raw, $config, $websiteId, $storeId, $siteId, $websiteSiteId, $assertSame
+): void {
+ $seed();
+ $save([
+ 'general/beacon_endpoint' => 'http://website.example.test/beacon',
+ 'general/brum_site_id' => $websiteSiteId, 'developer/development_mode' => '1',
+ ], ['website' => $websiteId]);
+ $save(['developer/development_mode' => '0'], ['store' => $storeId]);
+ $save([
+ 'general/beacon_endpoint' => 'http://store.example.test/beacon',
+ 'developer/development_mode' => null,
+ ], ['store' => $storeId]);
+ $assertSame(false, $raw('developer/development_mode', 'stores', $storeId), 'inherit deletes the override');
+ $runtime = $config->getRuntimeConfig('store', $storeId);
+ $assertSame('http://store.example.test/beacon', $runtime['beacon_endpoint'], 'store HTTP inherited from website');
+ $assertSame($websiteSiteId, $runtime['brum_site_id'], 'website identity inheritance');
+ $assertSame($siteId, $config->getRuntimeConfig('default')['brum_site_id'], 'default identity unchanged');
+ $save(['general/beacon_endpoint' => null], ['store' => $storeId]);
+ $assertSame('http://website.example.test/beacon', $config->getRuntimeConfig('store', $storeId)['beacon_endpoint'], 'endpoint inheritance');
+};
+$tests['website HTTP inheritance uses the default policy in the same form'] = static function () use (
+ $seed, $save, $raw, $config, $websiteId, $assertSame
+): void {
+ $seed();
+ $save(['developer/development_mode' => '1']);
+ $save(['developer/development_mode' => '0'], ['website' => $websiteId]);
+ $save([
+ 'general/beacon_endpoint' => 'http://website.example.test/beacon',
+ 'developer/development_mode' => null,
+ ], ['website' => $websiteId]);
+ $assertSame(false, $raw('developer/development_mode', 'websites', $websiteId), 'website override deleted');
+ $assertSame('http://website.example.test/beacon', $config->getRuntimeConfig('website', $websiteId)['beacon_endpoint'], 'default policy inherited');
+};
+$tests['native consent override and re-inheritance remain fail closed'] = static function () use ($seed, $save, $config, $storeId, $assertSame): void {
+ $seed();
+ $save(['consent/enabled' => '0'], ['store' => $storeId]);
+ $assertSame(false, $config->getRuntimeConfig('store', $storeId)['consent_enabled'], 'deliberate immediate override');
+ $save(['consent/enabled' => null], ['store' => $storeId]);
+ $assertSame(true, $config->getRuntimeConfig('store', $storeId)['consent_enabled'], 'inherit requires consent again');
+};
+$tests['missing identity can be saved but native rendering stays inactive'] = static function () use ($seed, $save, $config, $assertSame): void {
+ $seed();
+ $save(['general/brum_site_id' => '']);
+ $assertSame(null, $config->getRuntimeConfig(), 'missing identity runtime gate');
+ $assertSame('missing_site_id', $config->getStatus(), 'same Admin gate');
+};
+$tests['invalid imported values are rejected by native runtime configuration'] = static function () use (
+ $seed, $connection, $table, $scopeConfig, $config, $assertSame
+): void {
+ foreach ([Config::XML_PATH_BEACON_ENDPOINT => 'invalid_endpoint', Config::XML_PATH_BRUM_SITE_ID => 'invalid_site_id'] as $path => $status) {
+ $seed();
+ // Simulate a CLI/import bypass of Admin validation, still inside the rollback.
+ $connection->update($table, ['value' => 'invalid-import'], [
+ 'scope = ?' => 'default', 'scope_id = ?' => 0, 'path = ?' => $path,
+ ]);
+ $scopeConfig->reinit();
+ $assertSame(null, $config->getRuntimeConfig(), 'invalid imported value');
+ $assertSame($status, $config->getStatus(), 'Admin and runtime agree');
+ }
+};
+
+try {
+ foreach ($tests as $name => $test) {
+ $connection->beginTransaction();
+ try {
+ $connection->delete($table, ['path LIKE ?' => 'basicrum/%']);
+ $scopeConfig->reinit();
+ $test();
+ echo 'PASS: ' . $name . PHP_EOL;
+ } finally {
+ // Native Config saves use nested transactions. Roll back this
+ // process's outer transaction even after a backend exception.
+ while ($connection->getTransactionLevel() > 0) {
+ $connection->rollBack();
+ }
+ $scopeConfig->reinit();
+ }
+ }
+} finally {
+ $cacheState->setEnabled('config', $cacheEnabled);
+ $assertSame($original, $snapshot(), 'Configuration was not restored by rollback.');
+}
+echo count($tests) . ' native save checks passed; original configuration restored.' . PHP_EOL;
diff --git a/tests/integration/configure-disposable.sh b/tests/integration/configure-disposable.sh
new file mode 100755
index 0000000..3742ec9
--- /dev/null
+++ b/tests/integration/configure-disposable.sh
@@ -0,0 +1,58 @@
+#!/usr/bin/env sh
+set -eu
+
+if [ "${BASICRUM_DISPOSABLE_MAGENTO:-}" != "1" ]; then
+ echo "Set BASICRUM_DISPOSABLE_MAGENTO=1 only for a disposable Magento installation." >&2
+ exit 1
+fi
+
+if [ -z "${MAGENTO_ROOT:-}" ] || [ ! -x "${MAGENTO_ROOT}/bin/magento" ]; then
+ echo "MAGENTO_ROOT must point to a disposable Magento installation." >&2
+ exit 1
+fi
+
+if [ -z "${MAGENTO_STOREFRONT_URL:-}" ]; then
+ echo "MAGENTO_STOREFRONT_URL is required." >&2
+ exit 1
+fi
+
+module_root=$(CDPATH= cd -- "$(dirname -- "$0")/../.." && pwd)
+cd "$module_root"
+
+magento="${MAGENTO_ROOT}/bin/magento"
+
+if ! enabled_modules=$("$magento" module:status --enabled); then
+ echo "Unable to read enabled Magento modules from ${MAGENTO_ROOT}." >&2
+ exit 1
+fi
+
+if ! printf '%s\n' "$enabled_modules" | grep -Fxq 'Basicrum_Analytics'; then
+ echo "Basicrum_Analytics is not registered and enabled in ${MAGENTO_ROOT}." >&2
+ echo "Install this checkout at app/code/Basicrum/Analytics with exact casing and run setup:upgrade." >&2
+ exit 1
+fi
+
+# Fail before configuration writes instead of allowing npx to fetch an unpinned runner.
+if [ ! -x "$module_root/node_modules/.bin/playwright" ]; then
+ echo "Pinned Playwright is missing. Run npm ci in the module checkout first." >&2
+ exit 1
+fi
+
+"$magento" config:set basicrum/general/enabled 1
+"$magento" config:set basicrum/general/beacon_endpoint https://collector.basicrum.test/beacon
+"$magento" config:set basicrum/general/brum_site_id 550e8400-e29b-41d4-a716-446655440000
+"$magento" config:set basicrum/consent/enabled 1
+"$magento" config:set basicrum/privacy/strip_query_string 1
+"$magento" config:set basicrum/performance/wait_after_onload 0
+"$magento" config:set basicrum/performance/delay_ms 0
+"$magento" config:set basicrum/developer/development_mode 0
+"$magento" cache:clean config layout block_html full_page
+
+if [ "${BASICRUM_DEPLOY_STATIC:-0}" = "1" ]; then
+ "$magento" setup:static-content:deploy -f en_US
+fi
+
+MAGENTO_STOREFRONT_URL="$MAGENTO_STOREFRONT_URL" \
+MAGENTO_BEACON_URL=https://collector.basicrum.test/beacon \
+MAGENTO_SITE_ID=550e8400-e29b-41d4-a716-446655440000 \
+ "$module_root/node_modules/.bin/playwright" test --config=playwright.integration.config.js
diff --git a/tests/integration/csp.js b/tests/integration/csp.js
new file mode 100644
index 0000000..f2fd67a
--- /dev/null
+++ b/tests/integration/csp.js
@@ -0,0 +1,49 @@
+const { expect } = require("@playwright/test");
+const { beaconUrl } = require("./beacons");
+
+function policySources(csp, directive) {
+ const policy = csp.split(";")
+ .map((value) => value.trim().split(/\s+/))
+ .find((values) => values[0] === directive);
+ expect(policy, directive + " must be explicitly present").toBeTruthy();
+ return policy.slice(1);
+}
+
+function expectStorefrontCsp(csp) {
+ expect(csp).toBeTruthy();
+ for (const directive of ["connect-src", "img-src", "script-src"]) {
+ const sources = policySources(csp, directive);
+ expect(sources).toContain("'self'");
+ // Stable fixture from enabled Magento_Paypal/etc/csp_whitelist.xml.
+ // This detects losing the core whitelist collector as well as core config.
+ expect(sources).toContain("www.paypal.com");
+ if (directive !== "script-src") {
+ expect(sources).toContain(new URL(beaconUrl).origin);
+ }
+ }
+}
+
+function expectAdminCsp(headers) {
+ const policies = ["content-security-policy", "content-security-policy-report-only"]
+ .map((name) => headers[name]).filter(Boolean);
+ expect(policies.length).toBeGreaterThan(0);
+ for (const csp of policies) {
+ expect(policySources(csp, "script-src")).toContain("'self'");
+ for (const directive of ["connect-src", "img-src"]) {
+ expect(policySources(csp, directive)).not.toContain(new URL(beaconUrl).origin);
+ }
+ }
+}
+
+function expectEnforcingScriptCsp(headers, nonce) {
+ const csp = headers["content-security-policy"];
+ expect(csp, "An enforcing header is required, not report-only").toBeTruthy();
+ expectStorefrontCsp(csp);
+ const sources = policySources(csp, "script-src");
+ expect(sources).not.toContain("'unsafe-inline'");
+ expect(typeof nonce).toBe("string");
+ expect(nonce.length).toBeGreaterThan(0);
+ expect(sources, "Magento's inline bootstrap nonce must match the header").toContain(`'nonce-${nonce}'`);
+}
+
+module.exports = { expectAdminCsp, expectStorefrontCsp, expectEnforcingScriptCsp };
diff --git a/tests/integration/docker/Dockerfile b/tests/integration/docker/Dockerfile
new file mode 100644
index 0000000..90525ee
--- /dev/null
+++ b/tests/integration/docker/Dockerfile
@@ -0,0 +1,13 @@
+FROM composer:2.10@sha256:a5f59b9fd2faf31218632be4809dc6491761085e8064c31dc3b84378c48c248b AS composer
+FROM node:22.19.0-bookworm-slim@sha256:4a4884e8a44826194dff92ba316264f392056cbe243dcc9fd3551e71cea02b90 AS node
+FROM markoshust/magento-php:8.3-fpm-7@sha256:51ef90a707f02299caadbd0ea9b8b5ceea4082b740f3a4d9f91db2db874c5ee9
+USER root
+COPY --from=composer /usr/bin/composer /usr/local/bin/composer
+COPY --from=node /usr/local/bin/node /usr/local/bin/node
+COPY --from=node /usr/local/lib/node_modules /usr/local/lib/node_modules
+ENV PLAYWRIGHT_BROWSERS_PATH=/opt/playwright
+RUN ln -s ../lib/node_modules/npm/bin/npm-cli.js /usr/local/bin/npm \
+ && ln -s ../lib/node_modules/npm/bin/npx-cli.js /usr/local/bin/npx \
+ && npx --yes playwright@1.62.1 install --with-deps chromium
+RUN git config --system --add safe.directory /module
+USER app
diff --git a/tests/integration/docker/compose.yaml b/tests/integration/docker/compose.yaml
new file mode 100644
index 0000000..58bb89f
--- /dev/null
+++ b/tests/integration/docker/compose.yaml
@@ -0,0 +1,57 @@
+name: basicrum-release-baseline
+services:
+ php:
+ build: .
+ volumes:
+ - application:/var/www/html
+ - ../../..:/module
+ - ./php-fpm.conf:/usr/local/etc/php-fpm.conf:ro
+ environment:
+ BASICRUM_DISPOSABLE_MAGENTO: '1'
+ MAGENTO_ROOT: /var/www/html
+ MAGENTO_STOREFRONT_URL: https://web:8443/
+ MAGENTO_ADMIN_URL: https://web:8443/admin/
+ MAGENTO_ADMIN_USERNAME: basicrum-test
+ MAGENTO_ADMIN_PASSWORD: local-test-only-9471
+ depends_on:
+ db:
+ condition: service_healthy
+ search:
+ condition: service_healthy
+ web:
+ image: markoshust/magento-nginx:1.28-0@sha256:12d211176a5800ef1ebdc909865a0a5ebc691d282a6df438b082ac5587f275de
+ ports:
+ - '127.0.0.1:9443:8443'
+ volumes:
+ - application:/var/www/html:ro
+ - ./nginx.conf:/etc/nginx/conf.d/default.conf:ro
+ - ../../../.test-results/baseline-certs:/certs:ro
+ depends_on:
+ - php
+ db:
+ image: mariadb:10.11@sha256:07c0aaff7396b74cb7975cba78257178d188e30f531a5db2b617c48beef13c41
+ environment:
+ MARIADB_DATABASE: magento
+ MARIADB_USER: magento
+ MARIADB_PASSWORD: disposable
+ MARIADB_ROOT_PASSWORD: disposable-root
+ volumes:
+ - database:/var/lib/mysql
+ healthcheck:
+ test: ['CMD', 'healthcheck.sh', '--connect', '--innodb_initialized']
+ interval: 5s
+ retries: 30
+ search:
+ image: opensearchproject/opensearch:2.19.4@sha256:9e0b3b3b6805811bd63d9b9503ffe34a58ba33d03cc346000e318c6ff5c05bd9
+ environment:
+ discovery.type: single-node
+ DISABLE_SECURITY_PLUGIN: 'true'
+ DISABLE_INSTALL_DEMO_CONFIG: 'true'
+ OPENSEARCH_JAVA_OPTS: '-Xms512m -Xmx512m'
+ healthcheck:
+ test: ['CMD', 'curl', '-fsS', 'http://localhost:9200/_cluster/health']
+ interval: 10s
+ retries: 60
+volumes:
+ application:
+ database:
diff --git a/tests/integration/docker/install-artifact.sh b/tests/integration/docker/install-artifact.sh
new file mode 100644
index 0000000..ac134e4
--- /dev/null
+++ b/tests/integration/docker/install-artifact.sh
@@ -0,0 +1,14 @@
+#!/usr/bin/env sh
+set -eu
+test "${BASICRUM_DISPOSABLE_MAGENTO:-}" = 1
+test "${MAGENTO_ROOT:-}" = /var/www/html
+module_root=$(CDPATH= cd -- "$(dirname -- "$0")/../../.." && pwd)
+artifact="$module_root/.test-results/package/basicrum-analytics.zip"
+php "$module_root/tests/integration/check-artifact.php" "$artifact"
+cd "$MAGENTO_ROOT"
+# Exact destination inside this disposable stack. Do not delete stale files:
+# candidate verification below rejects them instead of concealing a bad upgrade.
+mkdir -p app/code/Basicrum/Analytics
+unzip -qo "$artifact" -d app/code/Basicrum/Analytics
+php "$module_root/tests/integration/check-installed-candidate.php"
+php bin/magento module:enable Basicrum_Analytics
diff --git a/tests/integration/docker/nginx.conf b/tests/integration/docker/nginx.conf
new file mode 100644
index 0000000..b61b024
--- /dev/null
+++ b/tests/integration/docker/nginx.conf
@@ -0,0 +1,18 @@
+upstream fastcgi_backend {
+ server php:9000;
+}
+server {
+ listen 8443 ssl;
+ server_name web;
+ ssl_certificate /certs/cert.pem;
+ ssl_certificate_key /certs/key.pem;
+ set $MAGE_ROOT /var/www/html;
+ # Disposable test challenge only. Magento's standard config otherwise rejects
+ # arbitrary PHP files; no such location or endpoint belongs on a real store.
+ location ~ "^/basicrum-test-[a-f0-9]{64}\.php$" {
+ include fastcgi_params;
+ fastcgi_param SCRIPT_FILENAME $MAGE_ROOT/pub$fastcgi_script_name;
+ fastcgi_pass fastcgi_backend;
+ }
+ include /var/www/html/nginx.conf.sample;
+}
diff --git a/tests/integration/docker/php-fpm.conf b/tests/integration/docker/php-fpm.conf
new file mode 100644
index 0000000..137b3e9
--- /dev/null
+++ b/tests/integration/docker/php-fpm.conf
@@ -0,0 +1,13 @@
+[global]
+daemonize = no
+error_log = /proc/self/fd/2
+
+[www]
+listen = 9000
+pm = dynamic
+pm.max_children = 8
+pm.start_servers = 2
+pm.min_spare_servers = 2
+pm.max_spare_servers = 4
+catch_workers_output = yes
+clear_env = no
diff --git a/tests/integration/docker/provision.sh b/tests/integration/docker/provision.sh
new file mode 100644
index 0000000..a29a8ad
--- /dev/null
+++ b/tests/integration/docker/provision.sh
@@ -0,0 +1,36 @@
+#!/usr/bin/env sh
+set -eu
+
+# Only this isolated Compose application is supported. Never point this at a live store.
+test "${BASICRUM_DISPOSABLE_MAGENTO:-}" = 1
+test "${MAGENTO_ROOT:-}" = /var/www/html
+cd "$MAGENTO_ROOT"
+if [ -f app/etc/env.php ]; then
+ echo 'Already installed. Provisioning refuses to overwrite an existing installation.' >&2
+ exit 1
+fi
+
+composer create-project --repository-url=https://mirror.mage-os.org/ \
+ --no-install --no-interaction magento/project-community-edition . 2.4.7-p10
+composer config repositories.magento composer https://mirror.mage-os.org/
+composer config --unset repositories.0
+composer install --no-dev --prefer-dist --no-interaction
+
+php bin/magento setup:install --base-url="$MAGENTO_STOREFRONT_URL" \
+ --base-url-secure="$MAGENTO_STOREFRONT_URL" --use-secure=1 --use-secure-admin=1 \
+ --db-host=db --db-name=magento --db-user=magento --db-password=disposable \
+ --backend-frontname=admin --admin-firstname=Basicrum --admin-lastname=Test \
+ --admin-email=admin@example.test --admin-user="$MAGENTO_ADMIN_USERNAME" \
+ --admin-password="$MAGENTO_ADMIN_PASSWORD" --language=en_US --currency=USD --timezone=UTC \
+ --search-engine=opensearch --opensearch-host=search --opensearch-port=9200
+# setup:install enables modules, so disable only after installation. Braintree
+# adds external scripts even without checkout; keep Magento_Paypal for CSP checks.
+php bin/magento module:disable Magento_AdminAdobeImsTwoFactorAuth Magento_TwoFactorAuth \
+ Magento_AdminAnalytics PayPal_Braintree PayPal_BraintreeGraphQl \
+ PayPal_BraintreeCustomerBalance PayPal_BraintreeGiftCardAccount PayPal_BraintreeGiftWrapping
+php bin/magento deploy:mode:set developer
+php bin/magento config:set system/smtp/disable 1
+# Admin Analytics is already disabled; its admin/usage/enabled field no longer exists.
+php bin/magento config:set web/secure/use_in_frontend 1
+php bin/magento cache:enable
+echo 'Pinned disposable Magento is installed. Install the module artifact before running the gate.'
diff --git a/tests/integration/docker/start.sh b/tests/integration/docker/start.sh
new file mode 100644
index 0000000..81e4b61
--- /dev/null
+++ b/tests/integration/docker/start.sh
@@ -0,0 +1,20 @@
+#!/usr/bin/env sh
+set -eu
+module_root=$(CDPATH= cd -- "$(dirname -- "$0")/../../.." && pwd)
+cd "$module_root"
+mkdir -p .test-results/baseline-certs
+# Only ignored output needs to be writable by the unprivileged container user.
+chmod a+rwx .test-results
+openssl req -x509 -nodes -newkey rsa:2048 \
+ -keyout .test-results/baseline-certs/key.pem -out .test-results/baseline-certs/cert.pem \
+ -days 7 -subj '/CN=web' -addext 'subjectAltName=DNS:web,IP:127.0.0.1'
+# The key is disposable and serves only this localhost-bound test stack.
+chmod 644 .test-results/baseline-certs/key.pem
+docker compose -f tests/integration/docker/compose.yaml build php
+docker compose -f tests/integration/docker/compose.yaml up -d db search php
+docker compose -f tests/integration/docker/compose.yaml exec -T --user root php chown app:app /var/www/html
+# Use the caller's host identity for the bind-mounted checkout, not container root.
+docker compose -f tests/integration/docker/compose.yaml exec -T \
+ --user "$(id -u):$(id -g)" -e npm_config_cache="/tmp/basicrum-npm-$(id -u)" -w /module php npm ci
+docker compose -f tests/integration/docker/compose.yaml exec -T php sh /module/tests/integration/docker/provision.sh
+docker compose -f tests/integration/docker/compose.yaml up -d web
diff --git a/tests/integration/enforcing-csp.spec.js b/tests/integration/enforcing-csp.spec.js
new file mode 100644
index 0000000..46b047c
--- /dev/null
+++ b/tests/integration/enforcing-csp.spec.js
@@ -0,0 +1,108 @@
+const { test, expect } = require("./fixtures");
+const { expectEnforcingScriptCsp } = require("./csp");
+const { beaconUrl, requestParameters, siteId } = require("./beacons");
+
+const storefrontUrl = process.env.MAGENTO_STOREFRONT_URL;
+test.skip(!storefrontUrl, "MAGENTO_STOREFRONT_URL is required");
+
+test.beforeEach(async ({ page }) => {
+ // Observe from document creation; never alter CSP, script nonces or production assets.
+ await page.addInitScript(() => {
+ window.__basicrumCspViolations = [];
+ document.addEventListener("securitypolicyviolation", event => {
+ window.__basicrumCspViolations.push({
+ directive: event.effectiveDirective,
+ disposition: event.disposition,
+ blockedURI: event.blockedURI.split("?")[0],
+ sourceFile: event.sourceFile.split("?")[0]
+ });
+ });
+ });
+});
+
+async function openEnforcingPage(page) {
+ const url = new URL("basicrumcsptest/", storefrontUrl);
+ const response = await page.goto(url.href, { waitUntil: "load" });
+ expect(response.status()).toBe(200);
+ expect(new URL(page.url()).pathname).toBe(url.pathname);
+ await expect(page.locator("#basicrum-csp-test")).toBeVisible();
+ // The fixture is deliberately uncacheable, like checkout. Do not certify nonce reuse on FPC HITs.
+ expect(response.headers()["cache-control"]).toMatch(/\bno-store\b/);
+ expect(response.headers()["x-magento-cache-debug"]).not.toBe("HIT");
+ // Text locators exclude script content; browsers also hide the nonce attribute.
+ // Inspect the actual DOM text and nonce property without modifying the script.
+ const nonces = await page.locator("script:not([src])").evaluateAll(scripts => scripts
+ .filter(script => script.textContent.includes("w.basicRumBoomerangConfig ="))
+ .map(script => script.nonce));
+ expect(nonces).toHaveLength(1);
+ const [nonce] = nonces;
+ expectEnforcingScriptCsp(response.headers(), nonce);
+ await page.waitForFunction(() => typeof window.OPT_IN_BASICRUM_LOADER_WRAPPER === "function");
+ return nonce;
+}
+
+test("native enforcing CSP permits first-party scripts and consent-gated beacons with fresh nonces", async ({
+ page, context, beaconTraffic, assetEvidence
+}) => {
+ const { beacons } = beaconTraffic;
+ const errors = [];
+ const bundles = [];
+ page.on("pageerror", error => errors.push(error.message));
+ page.on("request", request => {
+ if (request.url().includes("Basicrum_Analytics/js/boomr/boomerang-")) bundles.push(request.url());
+ });
+ let previousNonce;
+ for (let visit = 0; visit < 2; visit++) {
+ const before = beacons.length;
+ const nonce = await openEnforcingPage(page);
+ expect(nonce).not.toBe(previousNonce);
+ previousNonce = nonce;
+ await page.waitForTimeout(500);
+ expect(bundles).toHaveLength(visit);
+ expect(beacons).toHaveLength(before);
+ expect((await context.cookies()).filter(cookie => ["RT", "BA"].includes(cookie.name))).toEqual([]);
+ expect(await page.evaluate(() => window.BOOMR.version)).toBeUndefined();
+ expect(await page.evaluate(() => window.basicRumBoomerangConfig.beacon_url)).toBe(beaconUrl);
+
+ const loader = page.locator('script[src*="Basicrum_Analytics/js/loaders/consent-boomerang-loader-v1-15.min.js"]');
+ await expect(loader).toHaveCount(1);
+ expect(new URL(await loader.getAttribute("src"), page.url()).origin).toBe(new URL(storefrontUrl).origin);
+ await page.evaluate(() => {
+ window.OPT_IN_BASICRUM_LOADER_WRAPPER();
+ window.OPT_IN_BASICRUM_LOADER_WRAPPER();
+ });
+ await expect.poll(() => beacons.length).toBeGreaterThan(before);
+ expect(bundles).toHaveLength(visit + 1);
+ expect(new URL(bundles[visit]).origin).toBe(new URL(storefrontUrl).origin);
+ expect(await page.evaluate(() => window.BOOMR.version)).toBe("1.815.60");
+ const parameters = requestParameters(beacons[before]);
+ expect(parameters.get("p_gen")).toBe("mage2");
+ expect(parameters.get("p_type")).toBe("unmapped_basicrumcsptest_index_index");
+ expect(parameters.get("brum_site_id")).toBe(siteId);
+ expect(await assetEvidence()).toEqual(expect.arrayContaining([
+ "js/loaders/consent-boomerang-loader-v1-15.min.js",
+ "js/boomr/boomerang-1.815.60.cutting-edge.min.js"
+ ]));
+ await page.evaluate(() => window.OPT_OUT_BASICRUM_LOADER_WRAPPER());
+ expect((await context.cookies()).filter(cookie => ["RT", "BA"].includes(cookie.name))).toEqual([]);
+ expect(await page.evaluate(() => window.__basicrumCspViolations)).toEqual([]);
+ expect(errors).toEqual([]);
+ }
+});
+
+test("native enforcing CSP actually blocks an unnonced inline control script", async ({ page, beaconTraffic }) => {
+ await openEnforcingPage(page);
+ expect(await page.evaluate(() => window.__basicrumCspViolations)).toEqual([]);
+ // Deliberate negative control, not a replacement for the production bootstrap.
+ await page.evaluate(() => {
+ const script = document.createElement("script");
+ script.textContent = "window.__basicrumUnnoncedControlRan = true;";
+ document.body.appendChild(script);
+ });
+ await expect.poll(() => page.evaluate(() => window.__basicrumCspViolations.some(event =>
+ event.disposition === "enforce" && event.blockedURI === "inline" &&
+ ["script-src", "script-src-elem"].includes(event.directive)
+ ))).toBe(true);
+ expect(await page.evaluate(() => window.__basicrumUnnoncedControlRan)).toBeUndefined();
+ expect(beaconTraffic.beacons).toEqual([]);
+});
diff --git a/tests/integration/fixtures.js b/tests/integration/fixtures.js
new file mode 100644
index 0000000..4bc85b8
--- /dev/null
+++ b/tests/integration/fixtures.js
@@ -0,0 +1,57 @@
+const { test: base, expect } = require("@playwright/test");
+const { guardNetwork } = require("./beacons");
+const { localProxy } = require("./local-proxy");
+const { verifyInstallation } = require("./installation-proof");
+const { verifyAsset } = require("./served-assets");
+
+// Automatic: the guard is installed before any page, popup or navigation.
+const test = base.extend({
+ localNetwork: async ({}, use) => {
+ const proxy = await localProxy([process.env.MAGENTO_STOREFRONT_URL, process.env.MAGENTO_ADMIN_URL]);
+ try {
+ await use(proxy);
+ // Runs after context teardown, so late requests cannot escape the check.
+ expect(proxy.blocked, "Unexpected browser/transport requests were blocked").toEqual([]);
+ } finally {
+ await proxy.close();
+ }
+ },
+ proxy: async ({ localNetwork }, use) => use(localNetwork.options),
+ verifiedInstallation: [async ({ playwright, localNetwork }, use) => {
+ if (process.env.MAGENTO_STOREFRONT_URL) {
+ const request = await playwright.request.newContext({ proxy: localNetwork.options, ignoreHTTPSErrors: true });
+ try {
+ // The Admin path is a route, not a second document root. It must share this origin.
+ const storefront = new URL(process.env.MAGENTO_STOREFRONT_URL);
+ if (process.env.MAGENTO_ADMIN_URL) {
+ expect(new URL(process.env.MAGENTO_ADMIN_URL).origin).toBe(storefront.origin);
+ }
+ await verifyInstallation(request, process.env.MAGENTO_ROOT, [storefront.href]);
+ } finally {
+ await request.dispose();
+ }
+ }
+ await use();
+ }, { auto: true }],
+ assetEvidence: [async ({ context, verifiedInstallation }, use) => {
+ const checks = [];
+ context.on("response", response => {
+ // Capture failures as values immediately; report them after all pending reads.
+ checks.push(verifyAsset(response).catch(error => error));
+ });
+ const verify = async () => {
+ const results = await Promise.all(checks);
+ const failure = results.find(result => result instanceof Error);
+ if (failure) throw failure;
+ return results.filter(Boolean);
+ };
+ await use(verify);
+ await verify();
+ }, { auto: true }],
+ beaconTraffic: [async ({ context, localNetwork }, use) => {
+ const traffic = await guardNetwork(context, { blocked: localNetwork.blocked });
+ await use(traffic);
+ }, { auto: true }]
+});
+
+module.exports = { test, expect };
diff --git a/tests/integration/fixtures/Basicrum/CspTest/Controller/Index/Index.php b/tests/integration/fixtures/Basicrum/CspTest/Controller/Index/Index.php
new file mode 100644
index 0000000..233db78
--- /dev/null
+++ b/tests/integration/fixtures/Basicrum/CspTest/Controller/Index/Index.php
@@ -0,0 +1,21 @@
+pageFactory->create();
+ }
+}
diff --git a/tests/integration/fixtures/Basicrum/CspTest/etc/config.xml b/tests/integration/fixtures/Basicrum/CspTest/etc/config.xml
new file mode 100644
index 0000000..dd2266f
--- /dev/null
+++ b/tests/integration/fixtures/Basicrum/CspTest/etc/config.xml
@@ -0,0 +1,19 @@
+
+
+
+
+
+
+ 0
+
+
+
+
+
+ 0
+
+
+
+
+
+
diff --git a/tests/integration/fixtures/Basicrum/CspTest/etc/frontend/routes.xml b/tests/integration/fixtures/Basicrum/CspTest/etc/frontend/routes.xml
new file mode 100644
index 0000000..1dcb2fa
--- /dev/null
+++ b/tests/integration/fixtures/Basicrum/CspTest/etc/frontend/routes.xml
@@ -0,0 +1,8 @@
+
+
+
+
+
+
+
+
diff --git a/tests/integration/fixtures/Basicrum/CspTest/etc/module.xml b/tests/integration/fixtures/Basicrum/CspTest/etc/module.xml
new file mode 100644
index 0000000..c73e24d
--- /dev/null
+++ b/tests/integration/fixtures/Basicrum/CspTest/etc/module.xml
@@ -0,0 +1,9 @@
+
+
+
+
+
+
+
+
+
diff --git a/tests/integration/fixtures/Basicrum/CspTest/registration.php b/tests/integration/fixtures/Basicrum/CspTest/registration.php
new file mode 100644
index 0000000..1ef657f
--- /dev/null
+++ b/tests/integration/fixtures/Basicrum/CspTest/registration.php
@@ -0,0 +1,6 @@
+
+
+
+ Basicrum enforcing CSP test
+
+
+
+
+
+
+
+
diff --git a/tests/integration/fixtures/Basicrum/CspTest/view/frontend/templates/probe.phtml b/tests/integration/fixtures/Basicrum/CspTest/view/frontend/templates/probe.phtml
new file mode 100644
index 0000000..8cc14cc
--- /dev/null
+++ b/tests/integration/fixtures/Basicrum/CspTest/view/frontend/templates/probe.phtml
@@ -0,0 +1 @@
+