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
19 changes: 19 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,25 @@ even when calling `validate_token_online` — the SDK never contacts the API for
them. Use `validate_token_local` directly when you want the local-only check
explicitly.

## Revoking an Activation

To free up the activation seat for the current device — typically wired to a
"Deactivate" or "Sign out" button — call `revoke_activation` with the JWT:

```cpp
if (auto local = licensing.store().load_local_license()) {
licensing.revoke_activation(local->token); // server-side revoke + clears local store
}
```

On success the SDK both tells the server to release the seat and deletes the
matching license from the local store. Revoke is only meaningful for
online-activated paid licenses; calling it for offline or trial tokens raises
`operation_not_supported_error` without contacting the API. Server rejections
(`license_invalid_error`) and transport failures (`api_error`) propagate the
same way they do for `validate_token_online`, but with no grace-period
fallback — revoke is a one-shot operation.

## Custom Fingerprinting and Storage

```cpp
Expand Down
36 changes: 35 additions & 1 deletion docs/juce.md
Original file line number Diff line number Diff line change
Expand Up @@ -157,7 +157,41 @@ void timerCallback() override
`pollPendingActivation()` is non-blocking. The poll cadence is up to you;
once a second is plenty for a UI-driven flow.

To revoke locally: `unlockStatus.clearLicense();`
## Deactivating

Two paths, depending on whether you want to free the seat server-side:

- `revokeActivation()` / `revokeActivationAsync(callback)` — calls the
Moonbase backend to release this device's activation and then clears local
state. Use this for "Deactivate" / "Sign out" buttons in your UI so the
user can re-activate elsewhere without burning a seat.
- `clearLicense()` — local-only forget. Use it for offline or trial licenses
(which can't be revoked), or when you just want this device to stop
recognising the license without telling the server.

`revokeActivationAsync` is recommended for UI: the network call runs on a
`juce::Thread` and the callback is delivered on the message thread, with the
same generation-gating as `tryLoadStoredLicenseAsync` (a slow revoke can't
clobber a freshly activated license).

The callback receives a `RevokeOutcome`:

| Outcome | Meaning |
| --- | --- |
| `Revoked` | Seat freed server-side (or token was already gone). Bridge is now locked. |
| `NoLicense` | No license loaded — nothing to do. |
| `NotRevokable` | Token is offline-activated or a trial. Bridge state unchanged. Call `clearLicense()` if you still want a local forget. |
| `Unreachable` | Transport failure. Bridge state unchanged so the user can retry. |

```cpp
unlockStatus.revokeActivationAsync(
[this](auto outcome) {
using O = moonbase::juce_bridge::MoonbaseUnlockStatus::RevokeOutcome;
if (outcome == O::NotRevokable)
unlockStatus.clearLicense(); // trial / offline → local-only fallback
repaintActivationLabel();
});
```

## Gating features

Expand Down
188 changes: 188 additions & 0 deletions examples/juce/MoonbaseJuceBridge.h
Original file line number Diff line number Diff line change
Expand Up @@ -422,6 +422,194 @@ class MoonbaseUnlockStatus : public juce::OnlineUnlockStatus
});
}

enum class RevokeOutcome
{
Revoked, // Server-side revoke succeeded (or token was already gone). Bridge is now locked.
NoLicense, // No license is currently loaded — nothing to revoke. Bridge state unchanged.
NotRevokable, // Token is offline-activated or a trial. Bridge state unchanged.
// Caller can fall back to clearLicense() for a local-only forget.
Unreachable, // Transport failure (network down, 5xx). Bridge state unchanged so the
// caller can retry; no seat has been freed server-side.
};

// Server-side revoke of the currently loaded activation. On success, frees
// the seat on the Moonbase backend and clears the local store + bridge
// state. On NotRevokable / Unreachable the bridge state is left alone so
// the caller can decide between a local-only fallback (clearLicense()) or
// a retry.
//
// Synchronous: blocks the calling thread on libcurl. Prefer
// revokeActivationAsync() from UI threads.
RevokeOutcome revokeActivation()
{
std::string token;
std::string activationId;
{
const juce::ScopedLock lock(stateLock_);
if (!current_)
return RevokeOutcome::NoLicense;
token = current_->token;
activationId = current_->activation_id;
}

++validationGeneration_;

auto clearMatchingLocal = [&] {
try
{
auto stored = licensing_->store().load_local_license();
if (stored && stored->activation_id == activationId)
licensing_->store().delete_local_license();
}
catch (const moonbase::storage_error&) {}
};

try
{
licensing_->revoke_activation(token);
}
catch (const moonbase::operation_not_supported_error&)
{
return RevokeOutcome::NotRevokable;
}
catch (const moonbase::license_invalid_error&)
{
// Server says the token is unknown/invalid — treat as already gone.
// The SDK didn't reach its own store-cleanup, so do it here, gated
// on activation_id so we never wipe an unrelated cached license.
clearMatchingLocal();
setUnlocked(std::nullopt);
return RevokeOutcome::Revoked;
}
catch (const moonbase::license_expired_error&)
{
clearMatchingLocal();
setUnlocked(std::nullopt);
return RevokeOutcome::Revoked;
}
catch (const std::exception&)
{
return RevokeOutcome::Unreachable;
}

// SDK facade has already cleared the matching license from the store.
setUnlocked(std::nullopt);
return RevokeOutcome::Revoked;
}

// Non-blocking variant of revokeActivation.
//
// Threading: state mutation, store cleanup, and the callback all run on
// the JUCE message thread, regardless of which thread invokes this
// method. The network call runs on a juce::Thread.
//
// Staleness: same generation-gating as tryLoadStoredLicenseAsync — if the
// user calls clearLicense(), kicks off another async operation, or
// finishes a new activation while a revoke is in flight, the older
// continuation is dropped silently. Both state mutation AND the user
// callback are dropped, so a stale revoke can never overwrite the UI of
// a freshly activated license, and a destroyed bridge never receives
// its callback.
//
// Lifetime: licensing_ is a shared_ptr so the background thread keeps the
// SDK alive even if the bridge is destroyed mid-request. A
// juce::WeakReference protects the message-thread continuation from
// touching a destroyed bridge.
void revokeActivationAsync(std::function<void(RevokeOutcome)> onComplete = {})
{
std::string token;
std::string activationId;
{
const juce::ScopedLock lock(stateLock_);
if (current_)
{
token = current_->token;
activationId = current_->activation_id;
}
}

const auto generation = ++validationGeneration_;
juce::WeakReference<MoonbaseUnlockStatus> safeThis(this);
auto licensingHandle = licensing_;
auto cb = std::move(onComplete);

if (token.empty())
{
// Fast path: nothing loaded. Still go through the message-thread
// gate so a destroyed bridge or a superseding op silently drops
// the callback, matching the rest of the async surface.
juce::MessageManager::callAsync(
[safeThis, generation, cb = std::move(cb)]() mutable
{
auto* self = safeThis.get();
if (self == nullptr
|| generation != self->validationGeneration_.load())
return;
if (cb)
cb(RevokeOutcome::NoLicense);
});
return;
}

juce::Thread::launch(
[licensingHandle, token, activationId, generation, safeThis,
cb = std::move(cb)]() mutable
{
RevokeOutcome outcome = RevokeOutcome::Revoked;
try
{
licensingHandle->revoke_activation(token);
}
catch (const moonbase::operation_not_supported_error&)
{
outcome = RevokeOutcome::NotRevokable;
}
catch (const moonbase::license_invalid_error&)
{
outcome = RevokeOutcome::Revoked;
}
catch (const moonbase::license_expired_error&)
{
outcome = RevokeOutcome::Revoked;
}
catch (const std::exception&)
{
outcome = RevokeOutcome::Unreachable;
}

juce::MessageManager::callAsync(
[safeThis, generation, outcome, activationId,
licensingHandle, cb = std::move(cb)]() mutable
{
auto* self = safeThis.get();
if (self == nullptr
|| generation != self->validationGeneration_.load())
return;

if (outcome == RevokeOutcome::Revoked)
{
// Cover the invalid/expired server-response path
// where the SDK threw before its own store
// cleanup. activation_id matching ensures a stale
// revoke whose generation somehow still matches
// can't wipe an unrelated cached license.
try
{
auto stored = licensingHandle->store().load_local_license();
if (stored && stored->activation_id == activationId)
licensingHandle->store().delete_local_license();
}
catch (const moonbase::storage_error&) {}

self->setUnlocked(std::nullopt);
}

if (cb)
cb(outcome);
});
});
}

// Begins a new browser activation. Returns the URL to hand to
// juce::URL::launchInDefaultBrowser. Throws moonbase::api_error on
// network/server failure.
Expand Down
27 changes: 26 additions & 1 deletion examples/juce/PluginActivationComponent.h
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ class PluginActivationComponent : public juce::Component,
activateButton_.onClick = [this] { startActivation(); };

addAndMakeVisible(deactivateButton_);
deactivateButton_.onClick = [this] { unlockStatus_.clearLicense(); refreshLabel(); };
deactivateButton_.onClick = [this] { startRevoke(); };

// Async load: never blocks the message thread on libcurl. The label is
// updated optimistically from the local-validation result, then again
Expand Down Expand Up @@ -119,6 +119,31 @@ ERUn++6CVMPvZo67jVbTY+GCXYfW4gGVZQIDAQAB
}
}

void startRevoke()
{
refreshLabel("Revoking activation...");
unlockStatus_.revokeActivationAsync([this](auto outcome)
{
using O = moonbase::juce_bridge::MoonbaseUnlockStatus::RevokeOutcome;
switch (outcome)
{
case O::NotRevokable:
// Trial or offline-activated — server-side revoke isn't
// applicable, so fall back to a local-only forget.
unlockStatus_.clearLicense();
refreshLabel();
break;
case O::Unreachable:
refreshLabel("Could not reach Moonbase to revoke. Try again when online.");
break;
case O::Revoked:
case O::NoLicense:
refreshLabel();
break;
}
});
}

void refreshLabel(const juce::String& explicitMessage = {})
{
if (explicitMessage.isNotEmpty())
Expand Down
26 changes: 26 additions & 0 deletions include/moonbase/client.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,14 @@ inline std::string validate_path(const licensing_options& options)
"/validate";
}

inline std::string revoke_path(const licensing_options& options)
{
return trim_trailing_slashes(options.endpoint) +
"/api/client/licenses/" +
url_encode(options.product_id) +
"/revoke";
}

inline std::map<std::string, std::string> client_query(const licensing_options& options)
{
std::map<std::string, std::string> query{{"format", "JWT"}};
Expand Down Expand Up @@ -193,6 +201,24 @@ class license_client {
return validator_->validate_token(response.body);
}

void revoke_activation(std::string_view token) const
{
const auto url = detail::append_query(
detail::revoke_path(options_),
detail::client_query(options_));

http_request request;
request.method = "POST";
request.url = url;
request.headers = detail::default_headers("text/plain");
request.body = std::string(token);

const auto response = transport_->send(request);
if (response.status_code < 200 || response.status_code >= 300) {
detail::throw_for_problem(response.status_code, response.body);
}
}

[[nodiscard]] std::optional<license> get_requested_activation(
const activation_request& activation) const
{
Expand Down
9 changes: 9 additions & 0 deletions include/moonbase/errors.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ enum class error_type {
license_expired,
storage_error,
configuration_error,
operation_not_supported,
};

class moonbase_error : public std::runtime_error {
Expand Down Expand Up @@ -78,4 +79,12 @@ class storage_error : public moonbase_error {
}
};

class operation_not_supported_error : public moonbase_error {
public:
explicit operation_not_supported_error(const std::string& message)
: moonbase_error(error_type::operation_not_supported, message)
{
}
};

} // namespace moonbase
Loading
Loading