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
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,20 @@ All notable changes to **Tiger Core** (`webtigers/tiger-core`). Format follows

## [Unreleased]

### Security
- **TigerPASS activation now requires a positive, signed `valid` verdict from the authority.** Previously
`activatePass` only refused a definitive `lapsed` and accepted `unknown` (the ongoing nag-never-disable
fail-open) β€” so a well-formed but unprovable key (or an authority that couldn't cryptographically prove
entitlement) could unlock the premium shelf. Activation is now strict: only a reached-home,
signature-verified `valid` unlocks; `unknown`/`lapsed` are refused and the key is forgotten. (The
fail-open rule still applies to *ongoing* operation β€” an already-active install keeps running through an
authority outage.)

### Changed
- **The "Get TigerPASS" modal is now a lean key modal.** Replaced the two-phase sales pitch (plan cards,
feature shelf) with a single modal: a **Get My Key β†’** button that opens the seller's checkout, and a
paste-your-key field that activates. The pricing + value prop live on the checkout page, not the modal.

## [0.47.0-beta] β€” 2026-08-02

### Added
Expand Down
1 change: 1 addition & 0 deletions modules/system/languages/en/system.php
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
'system.pass.invalid_format' => 'That doesn\'t look like a TigerPASS key (it\'s a code like 019f88b1-7ce7-7467-95b3-db7a7433342c).',
'system.pass.not_configured' => 'TigerPASS isn\'t configured on this install yet.',
'system.pass.lapsed' => 'That subscription has lapsed β€” renew it at webtigers.com, then try again.',
'system.pass.unverified' => 'We couldn\'t verify that key with WebTigers. Check that you pasted it correctly, or try again in a moment.',
'system.pass.nag_snoozed' => 'TigerPASS reminder hidden for 30 days.',
'system.pass.nag_updated' => 'Preference saved.',
'system.source.connected' => 'Marketplace connected.',
Expand Down
21 changes: 14 additions & 7 deletions modules/system/services/Modules.php
Original file line number Diff line number Diff line change
Expand Up @@ -458,10 +458,14 @@ protected static function _slugId(string $id): string

/**
* Activate a TigerPASS subscription key on this install. Validates the key shape, remembers it under
* the reserved pass slug, and verifies it against the pass authority. NAG-NEVER-DISABLE: activation is
* refused ONLY on a definitive, reached-home `lapsed` verdict; an unreachable authority yields
* `unknown` and is accepted (assume-current β€” an authority outage must never block a paying customer).
* The heavy commerce (buy/renew) lives on webtigers.com; this endpoint only accepts the resulting key.
* the reserved pass slug, and verifies it against the pass authority. Activation requires a POSITIVE,
* SIGNED `valid` verdict β€” an unprovable key (a definitive `lapsed`, OR an `unknown` from an
* unreachable/untrusted authority β€” e.g. an unsigned reply, or one whose signature doesn't match the
* pinned public key) is refused and the key forgotten, so a random UUID can never unlock the premium
* shelf. This is deliberately STRICTER than the ongoing nag-never-disable gate: that fail-open keeps an
* ALREADY-active install running through an authority outage (renewal), but you can't ACTIVATE off a
* key we couldn't prove in the first place. The heavy commerce (buy/renew) lives on webtigers.com; this
* endpoint only accepts the resulting key.
*
* @param array $params the /api payload (expects `key`)
* @return void
Expand All @@ -488,9 +492,12 @@ public function activatePass(array $params): void
'public_key' => self::_passPublicKey(),
]);
$verdict = Tiger_License_Checker::verify(self::PASS_SLUG); // the one deliberate network check
if ($verdict['state'] === Tiger_License_Checker::LAPSED) {
Tiger_License_Checker::forget(self::PASS_SLUG); // don't keep a proven-lapsed key
$this->_error('system.pass.lapsed'); return;
if ($verdict['state'] !== Tiger_License_Checker::VALID) {
// Only a reached-home, signature-verified `valid` unlocks. `lapsed` = told no; anything else
// (`unknown`) = we couldn't prove it β€” either way, forget the key and never unlock.
Tiger_License_Checker::forget(self::PASS_SLUG);
$this->_error($verdict['state'] === Tiger_License_Checker::LAPSED ? 'system.pass.lapsed' : 'system.pass.unverified');
return;
}
$this->_success(['pass' => self::_passState()], 'system.pass.activated');
} catch (Throwable $e) {
Expand Down
63 changes: 12 additions & 51 deletions modules/system/views/scripts/modules/add.phtml
Original file line number Diff line number Diff line change
Expand Up @@ -546,70 +546,31 @@ document.addEventListener('DOMContentLoaded', function () {
var passModal = new bootstrap.Modal(passModalEl);
var passContent = document.getElementById('pass-content');

function passShelf(){
// The PASS-covered modules currently in the catalog β€” the "all this for $5?" shelf.
var names = allResults.filter(function(m){ return (m.availability||'')==='pass'; })
.map(function(m){ return m.module||m.slug; });
// De-dup + cap the visible list; always end with the "future" line.
var seen={}, uniq=[]; names.forEach(function(n){ if(n && !seen[n]){ seen[n]=1; uniq.push(n); } });
var rows = uniq.slice(0,7).map(function(n){ return '<div class="col-6 mb-1"><i class="fa-solid fa-check text-success me-2"></i>'+esc(n)+'</div>'; });
rows.push('<div class="col-6 mb-1 text-body-secondary fst-italic"><i class="fa-solid fa-check text-success me-2"></i>Every future premium module</div>');
return '<div class="row g-0 small mb-1">'+rows.join('')+'</div>';
}

function openPass(refName){
renderPassPhase1(refName);
function openPass(){
renderPassModal();
passModal.show();
}

function renderPassPhase1(refName){
var refBar = refName ? '<div class="alert alert-warning py-2 px-3 small d-flex gap-2 mb-3"><i class="fa-solid fa-paw mt-1"></i><div>You clicked <strong>'+esc(refName)+'</strong> β€” it\'s one of these. TigerPASS unlocks the whole shelf, not just this one.</div></div>' : '';
// A lean key modal: a "Get My Key" button (opens the seller's Shop checkout in a new tab β€” the sales
// pitch + pricing live THERE, not here) and a paste-key field that activates on a well-formed key.
function renderPassModal(){
passContent.innerHTML =
'<div class="modal-header border-0 pb-0">'+
'<div class="d-flex align-items-center gap-3">'+
'<span class="d-inline-flex align-items-center justify-content-center rounded-3 text-bg-warning" style="width:42px;height:42px;font-size:22px"><i class="fa-solid fa-paw"></i></span>'+
'<div><h5 class="modal-title mb-0" id="pass-title">Get TigerPASS</h5><div class="small text-body-secondary text-uppercase" style="letter-spacing:.04em">One subscription Β· every premium module</div></div>'+
'<div><h5 class="modal-title mb-0" id="pass-title">TigerPASS</h5><div class="small text-body-secondary text-uppercase" style="letter-spacing:.04em">One key Β· every premium module</div></div>'+
'</div>'+
'<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>'+
'</div>'+
'<div class="modal-body pt-2">'+
'<p class="mb-2"><strong class="text-warning-emphasis">$5/mo</strong> unlocks every premium module and theme β€” not just this one. One key, one domain, subdomains free.</p>'+
refBar +
passShelf() +
'<div class="d-grid gap-2 mt-3">'+
'<button type="button" class="btn btn-warning d-flex align-items-center justify-content-between" data-plan="monthly"><span class="fw-semibold">$5 <span class="fw-normal small">/ month</span></span><i class="fa-solid fa-arrow-right"></i></button>'+
'<button type="button" class="btn btn-outline-warning d-flex align-items-center justify-content-between" data-plan="yearly"><span class="fw-semibold">$50 <span class="fw-normal small">/ year</span></span><span class="badge text-bg-success-subtle text-success-emphasis border border-success-subtle">2 months free</span></button>'+
'</div>'+
'<button type="button" class="btn btn-link btn-sm text-body-secondary d-block mx-auto mt-2" data-bs-dismiss="modal">← or keep browsing free modules</button>'+
'<p class="text-body-secondary mb-0 mt-1" style="font-size:11px;line-height:1.4">Checkout happens on webtigers.com (Stripe). Your install never sees a card. Lapsing only pauses <em>updates</em> β€” installed modules keep running.</p>'+
'</div>';
passContent.querySelectorAll('[data-plan]').forEach(function(b){
b.addEventListener('click', function(){ renderPassPhase2(b.getAttribute('data-plan')); });
});
}

function renderPassPhase2(plan){
var priceLine = plan==='yearly' ? '$50/year' : '$5/month';
// Open the seller's hosted checkout in a new tab (popup window, never an iframe β€” 3DS + hosted
// checkout can't be framed). The modal stays here as the waiting/paste-key state.
try { window.open(PASS_CHECKOUT + (PASS_CHECKOUT.indexOf('?')<0?'?':'&') + 'plan=' + encodeURIComponent(plan), '_blank', 'noopener'); } catch(e){}
passContent.innerHTML =
'<div class="modal-header border-0 pb-0">'+
'<div class="d-flex align-items-center gap-3">'+
'<span class="d-inline-flex align-items-center justify-content-center rounded-3 text-bg-warning" style="width:42px;height:42px;font-size:22px"><i class="fa-solid fa-paw"></i></span>'+
'<div><h5 class="modal-title mb-0" id="pass-title">Complete your purchase</h5><div class="small text-body-secondary text-uppercase" style="letter-spacing:.04em">'+esc(priceLine)+' Β· checkout opened in a new tab</div></div>'+
'<div class="modal-body pt-3">'+
'<div class="d-grid mb-1">'+
'<a href="'+esc(PASS_CHECKOUT)+'" target="_blank" rel="noopener" class="btn btn-warning btn-lg d-flex align-items-center justify-content-center gap-2"><span class="fw-semibold">Get My Key</span><i class="fa-solid fa-arrow-right"></i></a>'+
'</div>'+
'<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>'+
'</div>'+
'<div class="modal-body pt-2">'+
'<p class="d-flex align-items-center gap-2 text-body-secondary small mb-3"><span class="spinner-border spinner-border-sm text-warning"></span> Waiting for your TigerPASS key…</p>'+
'<div class="d-flex align-items-center gap-2 text-body-secondary my-3" style="font-size:12px"><span class="flex-grow-1 border-top"></span>already have a key?<span class="flex-grow-1 border-top"></span></div>'+
'<label class="form-label small fw-semibold" for="pass-key">Paste your TigerPASS key</label>'+
'<input type="text" class="form-control font-monospace" id="pass-key" placeholder="xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" autocomplete="off" spellcheck="false" aria-describedby="pass-key-help">'+
'<div class="invalid-feedback" id="pass-key-err">That key isn\'t valid for this domain. Check it, or resend it to your email from webtigers.com.</div>'+
'<div class="form-text" id="pass-key-help">Finish checkout in the other tab β€” your key shows on the confirmation page (and is emailed to you). Paste it here and it activates automatically.</div>'+
'<div class="mt-3 pt-2 border-top">'+
'<a href="'+esc(PASS_CHECKOUT)+'" target="_blank" rel="noopener" class="small text-decoration-none"><i class="fa-solid fa-arrow-up-right-from-square me-1"></i>Reopen checkout</a>'+
'</div>'+
'<div class="form-text" id="pass-key-help">Your key is on the confirmation page after checkout (and emailed to you). Paste it here and it activates automatically.</div>'+
'</div>';
var input = document.getElementById('pass-key');
var errEl = document.getElementById('pass-key-err');
Expand Down Expand Up @@ -637,7 +598,7 @@ document.addEventListener('DOMContentLoaded', function () {
}).catch(function(){ pending=false; input.classList.add('is-invalid'); });
}
input.addEventListener('input', tryActivate);
input.focus();
setTimeout(function(){ try { input.focus(); } catch(e){} }, 200);
}

// ---- persistent value banner (only when there's no active PASS, and the user hasn't snoozed/disabled it) ----
Expand Down
50 changes: 50 additions & 0 deletions tests/Integration/System/ModulesServiceMarketplaceTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,9 @@
use PHPUnit\Framework\Attributes\Test;
use System_Service_Modules;
use Tiger\Tests\Support\IntegrationTestCase;
use Tiger_License_Checker;
use Tiger_Module_Registry;
use Zend_Config;
use Zend_Registry;

// System_Service_Modules resolves via the harness module autoloader (tests/bootstrap.php).
Expand All @@ -28,20 +30,35 @@ final class ModulesServiceMarketplaceTest extends IntegrationTestCase
{
private string $cacheDir = '';
private array $wrote = [];
private $priorConfig = null;

protected function setUp(): void
{
parent::setUp();
$this->cacheDir = rtrim(APPLICATION_ROOT, '/') . '/storage/cache';
@mkdir($this->cacheDir, 0775, true);
$this->priorConfig = Zend_Registry::isRegistered('Zend_Config') ? Zend_Registry::get('Zend_Config') : null;
}

protected function tearDown(): void
{
foreach ($this->wrote as $f) { @unlink($f); }
// Undo any pass-authority test wiring so tests don't bleed into each other.
Tiger_License_Checker::setTransport(null);
Tiger_License_Checker::forget('__tigerpass__'); // System_Service_Modules::PASS_SLUG
if ($this->priorConfig !== null) { Zend_Registry::set('Zend_Config', $this->priorConfig); }
parent::tearDown();
}

/** Wire a fake pass authority (config) + a canned authority reply (transport), the activatePass seam. */
private function wirePassAuthority(array $reply, string $publicKey = ''): void
{
Zend_Registry::set('Zend_Config', new Zend_Config([
'tiger' => ['pass' => ['authority' => 'https://authority.test', 'public_key' => $publicKey]],
], true));
Tiger_License_Checker::setTransport(static fn() => $reply);
}

/** Construct + dispatch the service on its message (routes on `action`), returning the response object. */
private function dispatch(array $msg): object
{
Expand Down Expand Up @@ -232,4 +249,37 @@ public function activate_pass_accepts_a_uuid_shaped_key_at_the_format_gate(): vo
'a UUID passes the format check (fails later as not-configured); a malformed key fails AT the format check'
);
}

#[Test]
public function activate_pass_unlocks_only_on_a_trusted_valid_verdict(): void
{
// The happy path: a reached-home, trusted `valid:true` (no pinned key here β†’ the raw reply is
// trusted) activates. This is the ONLY state that unlocks.
$this->loginAs('superadmin');
$this->wirePassAuthority(['valid' => true, 'ttl' => 3600]);
$res = $this->dispatch(['action' => 'activatePass', 'key' => '019f88b1-7ce7-7467-95b3-db7a7433342c']);
$this->assertSame(1, $res->result, 'a trusted valid=true verdict activates TigerPASS');
}

#[Test]
public function activate_pass_refuses_a_not_entitled_verdict(): void
{
// The authority says "not entitled" (valid:false) β†’ refused. A random/lapsed key never unlocks.
$this->loginAs('superadmin');
$this->wirePassAuthority(['valid' => false]);
$res = $this->dispatch(['action' => 'activatePass', 'key' => '019f88b1-7ce7-7467-95b3-db7a7433342c']);
$this->assertSame(0, $res->result, 'a valid=false verdict is refused β€” activation is not fail-open');
}

#[Test]
public function activate_pass_refuses_an_unsigned_reply_when_a_key_is_pinned(): void
{
// THE security regression guard. With a public key pinned, an UNSIGNED `valid:true` β€” exactly what
// the old always-say-yes stub returned β€” can't be trusted β†’ verdict `unknown` β†’ REFUSED. Activation
// demands a positive, cryptographically-proven verdict, so a random UUID can never unlock the shelf.
$this->loginAs('superadmin');
$this->wirePassAuthority(['valid' => true], 'cGlubmVkLXRlc3Qta2V5'); // no payload/signature in the reply
$res = $this->dispatch(['action' => 'activatePass', 'key' => '019f88b1-7ce7-7467-95b3-db7a7433342c']);
$this->assertSame(0, $res->result, 'an unsigned valid=true is refused (unknown), not accepted, when a key is pinned');
}
}
Loading