From 492da7fbf6a377f419c0e5f5dd71ed202a519c3e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tobias=20L=C3=B8nner=C3=B8d=20Madsen?= Date: Thu, 8 Oct 2026 18:05:28 +0200 Subject: [PATCH] fix: stop treating a closed store as an invalid license store_closed_error derived from license_invalid_error, so a store that had closed its Moonbase account locked the license, and apps that delete the stored license on an invalid one dropped a license the buyer still owns. It now derives from moonbase_error and reads like being offline: within the grace period validate_token_online keeps the license, and since the answer is not signed it relaxes nothing. When a closed store answers a validation with an offline license, the SDK stores it and never asks again, as for any offline license. The JUCE controller no longer locks on it, and deactivating on a closed store keeps the license and says why, since the device could not be activated again. Code that caught license_invalid_error to handle a closed store must catch store_closed_error instead. --- docs/core-sdk.md | 2 +- docs/juce-module.md | 4 +- include/moonbase/errors.hpp | 16 +-- include/moonbase/licensing.hpp | 1 + .../juce/ActivationConfig.h | 2 +- .../juce/ActivationController.cpp | 14 ++- .../moonbase_licensing/moonbase/errors.hpp | 16 +-- .../moonbase_licensing/moonbase/licensing.hpp | 1 + tests/client_tests.cpp | 14 ++- tests/juce/controller_tests.cpp | 106 +++++++++++++++++- tests/licensing_tests.cpp | 82 ++++++++++++-- 11 files changed, 210 insertions(+), 48 deletions(-) diff --git a/docs/core-sdk.md b/docs/core-sdk.md index dc0df2b..6f9770e 100644 --- a/docs/core-sdk.md +++ b/docs/core-sdk.md @@ -231,7 +231,7 @@ trying again can help: | `api_error` | No definitive answer. The API was unreachable (`status_code()` is 0), rate limited (429), failing (5xx, including the gateway's own 502 and 504), or something other than the API answered, such as a captive portal | Retry later; `retry_after()` says when, if the server did | | `license_invalid_error` | The API refused the license for good: a bad signature or product, or a revoked license or activation | Lock, and offer to activate again | | `license_expired_error` | The license has expired: a trial ended, or a subscription lapsed | Lock, and offer to buy | -| `store_closed_error` | The merchant closed their Moonbase account, so the store answers 410 to everything. A `license_invalid_error`, as in the .NET SDK. Offline licenses keep working | Lock; nothing can be checked online again | +| `store_closed_error` | The merchant closed their Moonbase account. No verdict on the license | Treat it like being offline | | `activation_request_error` | A browser activation request can never complete | Start a new request | | `configuration_error` | Bad options, or a device id resolver that returned an empty id | Fix the setup | diff --git a/docs/juce-module.md b/docs/juce-module.md index 3a86628..75cdcba 100644 --- a/docs/juce-module.md +++ b/docs/juce-module.md @@ -259,7 +259,7 @@ you add hidden with `addChildComponent` shows when it is needed. - **It says why.** The welcome screen swaps its title and body for the reason, from `controller.lockReason()`: `Deactivated` (here or in another instance), `Expired` (a subscription or offline license reached its end date), `Invalid` (Moonbase no longer - accepts it: revoked, store closed, or bound to another device) or `Unverified` (not + accepts it: revoked, or bound to another device) or `Unverified` (not checked online within `onlineGracePeriod`). A trial that ends gets the **Trial expired** screen instead. The copy is in `config.strings` (`deactivatedTitle`, `licenseExpiredBody`, and so on), and `lockReason()` goes back to `None` once a @@ -507,7 +507,7 @@ non-fatal: the current license is kept and the reason goes to `onDiagnostic`. So limiting, a server error, and a response that didn't come from Moonbase (a captive portal's sign-in page), as long as the license is within `onlineGracePeriod` of its last successful check. A definitive rejection is not: when the server says the license -was revoked or has lapsed, or that the store has closed, the controller drops it +was revoked or has lapsed, the controller drops it and shows the welcome screen, and `onActivationChanged` fires. The same happens when the grace period has run out and Moonbase still can't be reached. The license file stays, as it does when `start()` meets the same answer, so the next launch checks it again. Offline diff --git a/include/moonbase/errors.hpp b/include/moonbase/errors.hpp index 4a8a2fc..274cf8a 100644 --- a/include/moonbase/errors.hpp +++ b/include/moonbase/errors.hpp @@ -117,18 +117,14 @@ class license_device_mismatch_error : public license_invalid_error { } }; -// The merchant has closed their Moonbase account, so their store answers every -// request with 410 StoreClosed: nothing about a license can be checked, activated -// or renewed online again. Not transient, so it must not ride the offline grace -// period. An offline license keeps working, since it never asks the server. -// -// Derives from license_invalid_error, as the .NET SDK's StoreClosedException -// does, so existing catch sites and the grace-period logic treat it as the -// definitive answer it is. Code switching on type() must add the new case. -class store_closed_error : public license_invalid_error { +// The merchant has closed their Moonbase account (410 StoreClosed). Not a +// license_invalid_error: it says nothing against the license, so treat it like +// being offline. An activated device's validation gets a signed offline license +// from the closed store instead. +class store_closed_error : public moonbase_error { public: explicit store_closed_error(const std::string& message) - : license_invalid_error(error_type::store_closed, message) + : moonbase_error(error_type::store_closed, message) { } }; diff --git a/include/moonbase/licensing.hpp b/include/moonbase/licensing.hpp index c923ff3..03d076a 100644 --- a/include/moonbase/licensing.hpp +++ b/include/moonbase/licensing.hpp @@ -218,6 +218,7 @@ class licensing { } catch (const license_expired_error&) { throw; } catch (const std::exception&) { + // store_closed_error included: it is not signed, so it relaxes nothing. if (age <= options_.online_validation_grace_period) { return freshest; } diff --git a/modules/moonbase_licensing/juce/ActivationConfig.h b/modules/moonbase_licensing/juce/ActivationConfig.h index 1f158ae..75b13f8 100644 --- a/modules/moonbase_licensing/juce/ActivationConfig.h +++ b/modules/moonbase_licensing/juce/ActivationConfig.h @@ -72,7 +72,7 @@ enum class LockReason None, // licensed, or nothing to explain (never activated here) Deactivated, // deactivated or forgotten on this computer, here or in another instance Expired, // the license reached its end date (a trial also gets the Expired screen) - Invalid, // Moonbase no longer accepts it: revoked, store closed, or bound to another device + Invalid, // Moonbase no longer accepts it: revoked, or bound to another device Unverified, // it couldn't be checked online within onlineGracePeriod }; diff --git a/modules/moonbase_licensing/juce/ActivationController.cpp b/modules/moonbase_licensing/juce/ActivationController.cpp index 829471c..8027bf4 100644 --- a/modules/moonbase_licensing/juce/ActivationController.cpp +++ b/modules/moonbase_licensing/juce/ActivationController.cpp @@ -363,7 +363,7 @@ void ActivationController::start() } catch (const moonbase::license_invalid_error& ex) { - // Refused for good: revoked, store closed -> locked. + // Refused for good, such as revoked -> locked. diag = juce::String("Re-validating stored license failed: ") + describeError(ex); reason = LockReason::Invalid; } @@ -568,8 +568,7 @@ void ActivationController::refreshLicense(bool force, std::function catch (const moonbase::license_invalid_error& ex) { // The server refused the license for good: it was revoked or - // deleted, or the store has closed. Also not a network blip, so this - // locks too. + // deleted. Also not a network blip, so this locks too. rejected = true; diag = juce::String::fromUTF8(ex.what()); } @@ -875,8 +874,9 @@ void ActivationController::deactivate() juce::String diag; juce::String userMessage; // A license_invalid_error here means the server refused the token for good - // (it can't be revoked, or the store has closed), so forgetting it locally - // is all that is left to do. + // (it can't be revoked), so forgetting it locally is all that is left to + // do. On a closed store the license stays: this device could never be + // activated again. try { licensing->revoke_activation(token); @@ -889,7 +889,9 @@ void ActivationController::deactivate() outcome = Outcome::Unreachable; diag = describeError(ex); const auto failure = classifyFailure(ex); - if (failure == Failure::Busy) + if (failure == Failure::StoreClosed) + userMessage = "This store has closed, so this computer can't be deactivated."; + else if (failure == Failure::Busy) userMessage = "Moonbase couldn't deactivate right now. Try again in a minute."; else if (failure == Failure::Unexpected) userMessage = unexpectedFailure("Couldn't deactivate", ex); diff --git a/modules/moonbase_licensing/moonbase/errors.hpp b/modules/moonbase_licensing/moonbase/errors.hpp index 4a8a2fc..274cf8a 100644 --- a/modules/moonbase_licensing/moonbase/errors.hpp +++ b/modules/moonbase_licensing/moonbase/errors.hpp @@ -117,18 +117,14 @@ class license_device_mismatch_error : public license_invalid_error { } }; -// The merchant has closed their Moonbase account, so their store answers every -// request with 410 StoreClosed: nothing about a license can be checked, activated -// or renewed online again. Not transient, so it must not ride the offline grace -// period. An offline license keeps working, since it never asks the server. -// -// Derives from license_invalid_error, as the .NET SDK's StoreClosedException -// does, so existing catch sites and the grace-period logic treat it as the -// definitive answer it is. Code switching on type() must add the new case. -class store_closed_error : public license_invalid_error { +// The merchant has closed their Moonbase account (410 StoreClosed). Not a +// license_invalid_error: it says nothing against the license, so treat it like +// being offline. An activated device's validation gets a signed offline license +// from the closed store instead. +class store_closed_error : public moonbase_error { public: explicit store_closed_error(const std::string& message) - : license_invalid_error(error_type::store_closed, message) + : moonbase_error(error_type::store_closed, message) { } }; diff --git a/modules/moonbase_licensing/moonbase/licensing.hpp b/modules/moonbase_licensing/moonbase/licensing.hpp index c923ff3..03d076a 100644 --- a/modules/moonbase_licensing/moonbase/licensing.hpp +++ b/modules/moonbase_licensing/moonbase/licensing.hpp @@ -218,6 +218,7 @@ class licensing { } catch (const license_expired_error&) { throw; } catch (const std::exception&) { + // store_closed_error included: it is not signed, so it relaxes nothing. if (age <= options_.online_validation_grace_period) { return freshest; } diff --git a/tests/client_tests.cpp b/tests/client_tests.cpp index e65ed3a..d3ff30a 100644 --- a/tests/client_tests.cpp +++ b/tests/client_tests.cpp @@ -7,6 +7,7 @@ #include #include #include +#include #include @@ -933,7 +934,12 @@ const http_response store_closed{ } // namespace -TEST_CASE("a closed store is a definitive answer on every endpoint") +// Says nothing against the license, so code that deletes the stored license on +// a license_invalid_error must not see it. +static_assert(!std::is_base_of_v); +static_assert(!std::is_base_of_v); + +TEST_CASE("every endpoint reports a closed store as store_closed_error") { const auto expect_store_closed = [](const std::function& call) { try { @@ -942,8 +948,6 @@ TEST_CASE("a closed store is a definitive answer on every endpoint") } catch (const store_closed_error& ex) { CHECK(std::string(ex.what()) == "This store has closed."); CHECK(ex.type() == error_type::store_closed); - // A license_invalid_error, so grace and existing catch sites treat it as final. - CHECK(dynamic_cast(&ex) != nullptr); } }; @@ -991,8 +995,10 @@ TEST_CASE("a 410 that is not the API's StoreClosed stays retryable") try { (void)fixture.client.validate_token_online("token"); FAIL("expected validation to throw"); - } catch (const license_invalid_error&) { + } catch (const store_closed_error&) { FAIL("only the API's own StoreClosed answer is final"); + } catch (const license_invalid_error&) { + FAIL("a bare 410 is no verdict on the license"); } catch (const api_error& ex) { CHECK(ex.status_code() == 410); } diff --git a/tests/juce/controller_tests.cpp b/tests/juce/controller_tests.cpp index 8752e39..56a1ffa 100644 --- a/tests/juce/controller_tests.cpp +++ b/tests/juce/controller_tests.cpp @@ -694,6 +694,28 @@ TEST_CASE("deactivate() while Moonbase is rate limiting says to try again shortl CHECK(fx.licenseFile.existsAsFile()); } +TEST_CASE("deactivate() keeps the license when the store has closed") +{ + controller_fixture fx; + fx.seedStored(fx.token(default_claims())); + ActivationController controller(fx.config, fx.makeLicensing()); + controller.start(); + REQUIRE(pumpUntil([&] { return controller.screen() == Screen::Details; })); + + // The seat can't be freed, and nothing could be activated again, so + // forgetting the license would only take it away from the buyer. + fx.transport->responses.push_back(moonbase::http_response{ + 410, {}, R"({"title":"Store closed","status":410,"detail":"This store has closed.","errorType":"StoreClosed"})"}); + controller.deactivate(); + + REQUIRE(pumpUntil([&] { return ! controller.isBusy(); })); + CHECK(controller.screen() == Screen::Details); + REQUIRE(controller.license().has_value()); + CHECK(controller.licensedFlag().load()); + CHECK(controller.statusMessage().contains("This store has closed")); + CHECK(fx.licenseFile.existsAsFile()); +} + //============================================================================== // Offline activation flow (machine file out, license file in) //============================================================================== @@ -1187,8 +1209,6 @@ TEST_CASE("refreshLicense locks when the server rejects the license for good") moonbase::http_response{400, {}, R"({"title":"Invalid state","detail":"License has expired","status":400,"errorType":"LicenseExpired"})"}}, {"license no longer active", moonbase::http_response{400, {}, R"({"title":"Invalid state","detail":"License is no longer active","status":400})"}}, - {"store closed", - moonbase::http_response{410, {}, R"({"title":"Store closed","status":410,"detail":"This store has closed.","errorType":"StoreClosed"})"}}, {"activation revoked", moonbase::http_response{400, {}, R"({"title":"Not allowed","detail":"License has been revoked","status":400,"errorType":"LicenseActivationRevoked"})"}}, }; @@ -1221,6 +1241,88 @@ TEST_CASE("refreshLicense locks when the server rejects the license for good") } } +namespace { +const moonbase::http_response storeClosed{ + 410, {}, R"({"title":"Store closed","status":410,"detail":"This store has closed.","errorType":"StoreClosed"})"}; +} // namespace + +TEST_CASE("start() takes the offline license a closed store answers with, and never asks again") +{ + controller_fixture fx; + auto online = default_claims(); + online.erase("exp"); // bought outright + online["validated"] = now_seconds() - 60 * 60 * 24 * 365; // far past the grace period + fx.seedStored(fx.token(online)); + + // The same license as an offline one for this device, signed with the store's key. + auto offline = online; + offline["method"] = "Offline"; + offline["validated"] = now_seconds(); + offline.erase("p:rel"); + const auto offlineToken = fx.token(offline); + fx.transport->responses.push_back(moonbase::http_response{200, {}, offlineToken}); + + ActivationController controller(fx.config, fx.makeLicensing()); + controller.start(); + + REQUIRE(pumpUntil([&] { return settled(controller); })); + CHECK(controller.screen() == Screen::Details); + REQUIRE(controller.license().has_value()); + CHECK(controller.license()->method == moonbase::activation_method::offline); + CHECK(controller.lockReason() == LockReason::None); + CHECK(fx.store->load_local_license()->token == offlineToken); + + bool done = false; + controller.refreshLicense(true, [&](bool) { done = true; }); + REQUIRE(pumpUntil([&] { return done; })); + CHECK(controller.license().has_value()); + CHECK(fx.transport->requests.size() == 1); // offline licenses are not re-validated +} + +TEST_CASE("a closed store's StoreClosed reads like being offline") +{ + SUBCASE("within the grace period, refreshLicense keeps the license") + { + controller_fixture fx; + auto claims = default_claims(); + claims["trial"] = true; // a trial's validation is one of the calls a closed store refuses + fx.seedStored(fx.token(claims)); + ActivationController controller(fx.config, fx.makeLicensing()); + controller.start(); + REQUIRE(pumpUntil([&] { return controller.screen() == Screen::Trial; })); + + fx.transport->responses.push_back(storeClosed); + bool done = false, ok = true; + controller.refreshLicense(true, [&](bool refreshed) { done = true; ok = refreshed; }); + + REQUIRE(pumpUntil([&] { return done; })); + CHECK_FALSE(ok); + CHECK(controller.license().has_value()); + CHECK(controller.screen() == Screen::Trial); + CHECK(fx.licenseFile.existsAsFile()); + } + + SUBCASE("past it, start() locks as unverified and keeps the file") + { + controller_fixture fx; + fx.config.onlineGracePeriod = std::chrono::seconds(20); + auto claims = default_claims(); + claims["trial"] = true; + claims["validated"] = now_seconds() - 60; + fx.seedStored(fx.token(claims)); + fx.transport->responses.push_back(storeClosed); + + ActivationController controller(fx.config, fx.makeLicensing()); + controller.start(); + + REQUIRE(pumpUntil([&] { return settled(controller); })); + CHECK(controller.screen() == Screen::Welcome); + CHECK_FALSE(controller.license().has_value()); + CHECK(controller.lockReason() == LockReason::Unverified); + CHECK(fx.licenseFile.existsAsFile()); + } +} + TEST_CASE("refreshLicense keeps the license through what only looks like a verdict") { const std::vector> blips{ diff --git a/tests/licensing_tests.cpp b/tests/licensing_tests.cpp index 3b19c15..bfe61be 100644 --- a/tests/licensing_tests.cpp +++ b/tests/licensing_tests.cpp @@ -149,23 +149,81 @@ TEST_CASE("validate_token_online propagates definitive license errors regardless } } -TEST_CASE("a closed store is not kept alive by the grace period") +namespace { + +const http_response store_closed_response{ + 410, + {}, + R"({"title":"Store closed","status":410,"detail":"This store has closed.","errorType":"StoreClosed"})"}; + +constexpr long long one_year = 365LL * 24 * 60 * 60; + +} // namespace + +TEST_CASE("a closed store's offline license replaces the online one and is never sent again") { licensing_options options; - options.online_validation_grace_period = std::chrono::hours(24 * 365); + options.online_validation_grace_period = std::chrono::hours(1); facade_fixture fixture(options); - auto stale = moonbase::tests::default_claims(); - stale["validated"] = moonbase::tests::now_seconds() - (10 * 60); - const auto stale_token = fixture.make_token(stale); + // Bought outright (no exp), and last checked a year ago. + auto online = moonbase::tests::default_claims(); + online.erase("exp"); + online["validated"] = moonbase::tests::now_seconds() - one_year; + const auto online_token = fixture.make_token(online); - fixture.transport->responses.push_back(http_response{ - 410, - {}, - R"({"title":"Store closed","status":410,"detail":"This store has closed.","errorType":"StoreClosed"})"}); - CHECK_THROWS_AS( - (void)fixture.instance.validate_token_online(stale_token), - store_closed_error); + // What a closed store answers: the same license, offline, signed with its key. + auto offline = online; + offline["method"] = "Offline"; + offline["validated"] = moonbase::tests::now_seconds(); + offline.erase("p:rel"); + const auto offline_token = fixture.make_token(offline); + fixture.transport->responses.push_back(http_response{200, {}, offline_token}); + + const auto result = fixture.instance.validate_token_online(online_token); + + CHECK(result.method == activation_method::offline); + CHECK(result.token == offline_token); + CHECK_FALSE(result.expires_at.has_value()); + const auto stored = fixture.instance.store().load_local_license(); + REQUIRE(stored.has_value()); + CHECK(stored->token == offline_token); + + // From here on it is checked on the device alone. + CHECK(fixture.instance.validate_token_online(offline_token).token == offline_token); + // An instance still holding the online token picks up the stored offline one. + CHECK(fixture.instance.validate_token_online(online_token).token == offline_token); + CHECK(fixture.transport->requests.size() == 1); +} + +TEST_CASE("a closed store's StoreClosed reads like being offline") +{ + licensing_options options; + options.online_validation_grace_period = std::chrono::hours(1); + facade_fixture fixture(options); + + SUBCASE("within the grace period, the license is kept") + { + auto claims = moonbase::tests::default_claims(); + claims["validated"] = moonbase::tests::now_seconds() - (10 * 60); + const auto token = fixture.make_token(claims); + fixture.transport->responses.push_back(store_closed_response); + + const auto result = fixture.instance.validate_token_online(token); + + CHECK(result.token == token); + CHECK(fixture.transport->requests.size() == 1); + } + + SUBCASE("past it, the closed store is reported, and nothing is relaxed") + { + auto claims = moonbase::tests::default_claims(); + claims["validated"] = moonbase::tests::now_seconds() - (2 * 60 * 60); + const auto token = fixture.make_token(claims); + fixture.transport->responses.push_back(store_closed_response); + + CHECK_THROWS_AS((void)fixture.instance.validate_token_online(token), store_closed_error); + } } TEST_CASE("validate_token_online rides out what only looks like a verdict within grace")