Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 5 additions & 1 deletion library/Tiger/Module/Discovery.php
Original file line number Diff line number Diff line change
Expand Up @@ -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<string,array> module metadata rows keyed by slug (sorted)
*/
Expand Down Expand Up @@ -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/_<x> 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.
Expand Down
4 changes: 3 additions & 1 deletion modules/system/controllers/ModulesController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
5 changes: 5 additions & 0 deletions modules/system/services/Modules.php
Original file line number Diff line number Diff line change
Expand Up @@ -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];

Expand Down
18 changes: 18 additions & 0 deletions tests/Unit/Module/DiscoveryTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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' => "<?php\n",
]);
$this->plantAppModule('fixnormal', [
'module.json' => json_encode(['slug' => 'fixnormal', 'name' => 'Fix Normal']),
'Bootstrap.php' => "<?php\n",
]);

$all = Tiger_Module_Discovery::all();
$this->assertTrue($all['fixprotected']['protected'], 'protected:true carries through');
$this->assertFalse($all['fixnormal']['protected'], 'absent -> false (deactivatable)');
}

#[Test]
public function aBareDirWithNoModuleSignalsIsSkipped(): void
{
Expand Down
Loading