From 8f1c65734d53105b2293429d747d4f5a5413f496 Mon Sep 17 00:00:00 2001 From: "Beau Beauchamp, WebTigers" Date: Wed, 23 Sep 2026 13:10:05 -0400 Subject: [PATCH] Modules: honor a manifest "protected": true (always-on) flag MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Beyond the hardcoded core protected set (default/system/access), a module can declare "protected": true in its module.json to be non-deactivatable — for an install that must not run without it (e.g. TigerPanel inside a hosted account). - Tiger_Module_Discovery surfaces `protected` from the manifest. - System_Service_Modules::_toggle refuses to deactivate a protected module. - ModulesController row flag = the hardcoded set OR the manifest flag (set on $m so it wins the union; fixes the system-row regression that surfaced when Discovery began emitting `protected`). - DiscoveryTest: protected:true passes through, absent -> false. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01ASauLLscjqdsNqBNsx2Typ --- CHANGELOG.md | 8 ++++++++ library/Tiger/Module/Discovery.php | 6 +++++- .../system/controllers/ModulesController.php | 4 +++- modules/system/services/Modules.php | 5 +++++ tests/Unit/Module/DiscoveryTest.php | 18 ++++++++++++++++++ 5 files changed, 39 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e2a91306..3098d9fe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,14 @@ All notable changes to **Tiger Core** (`webtigers/tiger-core`). Format follows ## [Unreleased] +### Added + +- **Modules: manifest-driven `protected` (always-on) flag.** A module that declares `"protected": true` + in its `module.json` can be installed and updated but **never deactivated** in the Module manager — + beyond the hardcoded core set (`default`/`system`/`access`) — for an install that must not run without + it (e.g. TigerPanel inside a hosted account). `Tiger_Module_Discovery` surfaces the flag; the Modules + screen marks the row protected and `System_Service_Modules` refuses to deactivate it. + ## [1.14.0] — 2026-09-23 ### Added diff --git a/library/Tiger/Module/Discovery.php b/library/Tiger/Module/Discovery.php index 01e41bf7..2c5fed14 100644 --- a/library/Tiger/Module/Discovery.php +++ b/library/Tiger/Module/Discovery.php @@ -14,7 +14,7 @@ class Tiger_Module_Discovery { /** - * All modules on disk, keyed by slug: {slug, area, name, version, description, author, license, homepage, pricing, has_manifest}. + * All modules on disk, keyed by slug: {slug, area, name, version, description, author, license, homepage, pricing, protected, has_manifest}. * * @return array module metadata rows keyed by slug (sorted) */ @@ -65,6 +65,10 @@ public static function all() 'license' => (string) ($m['license'] ?? ''), 'homepage' => (string) ($m['homepage'] ?? ''), 'pricing' => $m['pricing']['model'] ?? null, + // A module declaring `"protected": true` in its manifest can't be deactivated in the + // Module manager — for an always-on module an install must not run without (e.g. + // TigerPanel inside a hosted account). Beyond the hardcoded core protected set. + 'protected' => !empty($m['protected']), 'asset_base' => (string) ($m['assetBase'] ?? ''), // themes: the public/_ symlink base // Advisory compatibility metadata (min/max tested Tiger version) — passed through // for Tiger_Module_Compat to interpret; legacy `requires.tiger` doubles as the min. diff --git a/modules/system/controllers/ModulesController.php b/modules/system/controllers/ModulesController.php index c7c04dab..f78a0ad5 100644 --- a/modules/system/controllers/ModulesController.php +++ b/modules/system/controllers/ModulesController.php @@ -44,10 +44,12 @@ public function indexAction() $rowArr = $row ? $row->toArray() : []; if (!empty($rowArr['type'])) { $m['type'] = (string) $rowArr['type']; } if (!empty($rowArr['category'])) { $m['category'] = array_values(array_filter(explode(',', (string) $rowArr['category']))); } + // Protected = the hardcoded core set OR the module's manifest `"protected": true` (Discovery + // put that in $m). Set it on $m so it wins the union below (which keeps left-hand keys). + $m['protected'] = !empty($m['protected']) || in_array($slug, System_Service_Modules::PROTECTED, true); $modules[] = $m + [ 'active' => $active, 'source' => $source, - 'protected' => in_array($slug, System_Service_Modules::PROTECTED, true), // Advisory: tested-version compat notice (never blocks) + who requires this module // (drives the "required by X, Y — deactivate anyway?" confirm; empty for most). 'compat' => Tiger_Module_Compat::check($m), diff --git a/modules/system/services/Modules.php b/modules/system/services/Modules.php index 09636ef7..0f1afee7 100644 --- a/modules/system/services/Modules.php +++ b/modules/system/services/Modules.php @@ -134,6 +134,11 @@ protected function _toggle(array $params, $on): void $discovered = Tiger_Module_Discovery::all(); if (!isset($discovered[$slug])) { $this->_error('system.error.unknown'); return; } + // A module can declare itself always-on with `"protected": true` in its manifest (beyond the + // hardcoded core set above) — an install that must not run without it (e.g. TigerPanel inside a + // hosted account). It can be installed/updated, never deactivated. + if (!$on && !empty($discovered[$slug]['protected'])) { $this->_error('system.error.protected'); return; } + try { $d = $discovered[$slug]; diff --git a/tests/Unit/Module/DiscoveryTest.php b/tests/Unit/Module/DiscoveryTest.php index 828d5d90..bc0edb28 100644 --- a/tests/Unit/Module/DiscoveryTest.php +++ b/tests/Unit/Module/DiscoveryTest.php @@ -183,6 +183,24 @@ public function requiresAndCompatPassThroughFromTheManifest(): void $this->assertSame(['tiger' => ['min' => '0.36.0-beta', 'max' => '0.40.0-beta']], $row['compat']); } + #[Test] + public function protectedFlagPassesThroughFromTheManifest(): void + { + // `"protected": true` marks an always-on module the Module manager must refuse to deactivate. + $this->plantAppModule('fixprotected', [ + 'module.json' => json_encode(['slug' => 'fixprotected', 'name' => 'Fix Protected', 'protected' => true]), + 'Bootstrap.php' => "plantAppModule('fixnormal', [ + 'module.json' => json_encode(['slug' => 'fixnormal', 'name' => 'Fix Normal']), + 'Bootstrap.php' => "assertTrue($all['fixprotected']['protected'], 'protected:true carries through'); + $this->assertFalse($all['fixnormal']['protected'], 'absent -> false (deactivatable)'); + } + #[Test] public function aBareDirWithNoModuleSignalsIsSkipped(): void {