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
18 changes: 18 additions & 0 deletions docs/core-sdk.md
Original file line number Diff line number Diff line change
Expand Up @@ -89,11 +89,29 @@ options.endpoint = "https://demo.moonbase.sh";
options.product_id = "demo-app";
options.public_key = public_key_pem;
options.account_id = "tenant-id"; // optional issuer check
options.client_info = "my-framework/1.2.0"; // optional, see below
options.http_connect_timeout = std::chrono::seconds(10);
options.http_request_timeout = std::chrono::seconds(30);

moonbase::licensing licensing(options);
```

`client_info` identifies a higher-level integration built on top of the SDK (the
JUCE module sets `moonbase-juce/<version> (JUCE …; OS)`, for example). It is
appended to the `User-Agent` after `moonbase-cpp/<version>`, so requests report
every layer, outermost last:

```
User-Agent: moonbase-cpp/4.3.1 my-framework/1.2.0
```

Use product tokens (`Name/Version`, with an optional `(comment)`) and keep it
ASCII. If your code sits on top of another integration that already set it,
append a segment rather than replacing the string. Control characters are
stripped and the value is capped at 256 characters when the header is built, so
a stray newline can never inject a header.

```cpp
auto request = licensing.request_activation();
std::cout << "Open: " << request.browser_url << "\n";

Expand Down
31 changes: 31 additions & 0 deletions docs/juce-module.md
Original file line number Diff line number Diff line change
Expand Up @@ -499,3 +499,34 @@ The collected map flows into `moonbase::licensing_options::metadata` and is sent
SDK's requests. When you don't set `config.applicationVersion`, it auto-fills from
`JucePlugin_VersionString` in a plugin build (or the running app's version otherwise), so
telemetry reports a version without extra wiring.

### Identifying an integration built on the module

Every request carries a layered `User-Agent`: the SDK, then this module, then anything a
higher-level integration adds. Out of the box:

```
User-Agent: moonbase-cpp/4.3.1 moonbase-juce/4.3.1 (JUCE v8.0.4; macOS 15.2)
```

A framework, wrapper or white-label host that embeds the module can add its own segment
with `config.clientInfo`:

```cpp
config.clientInfo << " HISE/4.1.0"; // append, don't assign
```

```
User-Agent: moonbase-cpp/4.3.1 moonbase-juce/4.3.1 (JUCE v8.0.4; macOS 15.2) HISE/4.1.0
```

Your segment is appended *after* the module's own, never in place of it, so support and
analytics still see which module and SDK version ran underneath. Append with `<<` rather
than assigning, so a stack of layers (framework, then a plugin built on it) each keeps its
mark.

Use product tokens (`Name/Version`, with an optional `(comment)`), keep it ASCII, and keep
it short: control characters are stripped and the whole segment is capped at 256 characters
before it reaches the header. Unlike the analytics capture above, this is sent on every
request and is not gated by `config.analytics.enabled`; it identifies the software, not the
machine or the user.
41 changes: 39 additions & 2 deletions include/moonbase/client.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,38 @@ inline std::string version_string()
#endif
}

// Every transport we ship assembles headers into a single line, so a CR or LF in
// a caller-supplied client_info would inject a header and an embedded NUL would
// silently truncate one. Control characters become spaces, runs of whitespace
// collapse, and the result is trimmed and capped, so the User-Agent stays a
// well-formed, bounded token list however many integration layers appended to it.
inline std::string sanitize_client_info(const std::string& value)
{
// Generous for a stack of "Name/Version (comment)" segments, and well under
// the per-line header limits proxies enforce: an oversized User-Agent fails
// as an opaque 400/431 that is undebuggable from the field.
constexpr std::string::size_type max_length = 256;

std::string result;
for (const char character : value) {
const auto byte = static_cast<unsigned char>(character);
if (byte < 0x20 || byte == 0x7F || byte == ' ') {
if (!result.empty() && result.back() != ' ') {
result.push_back(' '); // leading runs drop, interior runs collapse
}
} else {
result.push_back(character);
}
if (result.size() >= max_length) {
break;
}
}
while (!result.empty() && result.back() == ' ') {
result.pop_back();
}
return result;
}

inline std::string request_path(const licensing_options& options)
{
return trim_trailing_slashes(options.endpoint) +
Expand Down Expand Up @@ -74,8 +106,13 @@ inline std::map<std::string, std::string> default_headers(const licensing_option
const std::string& content_type = {})
{
std::string user_agent = "moonbase-cpp/" + version_string();
if (options.client_info && !options.client_info->empty()) {
user_agent += " " + *options.client_info;
if (options.client_info) {
// Sanitise before the emptiness check: a segment that is only whitespace
// or control characters must not leave a dangling separator behind.
const auto client_info = sanitize_client_info(*options.client_info);
if (!client_info.empty()) {
user_agent += " " + client_info;
}
}
std::map<std::string, std::string> headers{
{"Accept", "application/json, application/jwt, text/plain"},
Expand Down
6 changes: 6 additions & 0 deletions include/moonbase/types.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,12 @@ struct licensing_options {
// Identifies a higher-level integration built on top of the SDK (e.g. the
// JUCE module). Appended to the User-Agent after "moonbase-cpp/<version>" so
// the server can tell which client made the request.
//
// Each layer appends its own space-separated segment rather than replacing
// what is already there, so the header reads outermost-last:
// "moonbase-cpp/4.3.1 moonbase-juce/4.3.1 (JUCE v8.0.4; macOS 15.2) HISE/4.1.0".
// Control characters are stripped and the segment is capped before it reaches
// the header (see detail::sanitize_client_info).
std::optional<std::string> client_info;
std::map<std::string, std::string> metadata;
std::chrono::milliseconds http_connect_timeout{std::chrono::seconds{10}};
Expand Down
3 changes: 3 additions & 0 deletions modules/moonbase_licensing/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,9 @@ richer gating, and `onActivationChanged` fires whenever it changes.
- **Telemetry** — `config.analytics.enabled = true` attaches JUCE system/host metadata
(OS, CPU, DAW host, plugin format, …) to activation requests; add your own via
`config.metadata` / `config.onCollectMetadata`.
- **Building on top of the module.** A framework or wrapper that embeds it identifies
itself with `config.clientInfo << " HISE/4.1.0"`, which is appended to the `User-Agent`
after the module's own `moonbase-juce/<version>` segment rather than replacing it.

See [`docs/juce-module.md`](../../docs/juce-module.md) for the full guide and
[`examples/juce-native/`](../../examples/juce-native/) for a runnable sample app.
Expand Down
47 changes: 40 additions & 7 deletions modules/moonbase_licensing/juce/ActivationConfig.h
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,20 @@ struct ActivationConfig
juce::String accountId; // optional issuer pin
juce::String applicationVersion;

// An extra User-Agent segment identifying the layer built on top of this
// module: a framework, a wrapper, a white-label host, e.g. "HISE/4.1.0".
// Appended after the module's own "moonbase-juce/<version> (...)" segment,
// never in place of it, so support and analytics still see which module
// version ran underneath. Sent on every request (not gated by analytics).
//
// Append rather than assign, so a stack of layers each keeps its mark:
// config.clientInfo << " MyWrapper/2.0";
//
// Use product tokens ("Name/Version", optional "(comment)") and keep it
// ASCII. Control characters are stripped and the segment is capped before it
// reaches the header. Read once, when the controller is constructed.
juce::String clientInfo;

//== Validation / network tuning ==========================================
// How long a license stays valid offline since its last successful online
// validation before it is treated as stale (and the app locks). Default 7 days.
Expand Down Expand Up @@ -306,6 +320,29 @@ struct ActivationConfig
return juce::File::getSpecialLocation(juce::File::tempDirectory);
}

// What the module reports as the client in the User-Agent: its own segment
// (module version + JUCE version + OS, for support and analytics), then the
// consumer's clientInfo when set. The base client prefixes
// "moonbase-cpp/<version>", so the wire value reads outermost-last:
//
// moonbase-cpp/4.3.1 moonbase-juce/4.3.1 (JUCE v8.0.4; macOS 15.2) HISE/4.1.0
//
// Trim-and-skip only: the SDK strips control characters and caps the length
// when it builds the header, so the character policy lives in one place.
[[nodiscard]] juce::String resolvedClientInfo() const
{
juce::String resolved;
resolved << "moonbase-juce/" << MOONBASE_LICENSING_VERSION
<< " (" << juce::SystemStats::getJUCEVersion()
<< "; " << juce::SystemStats::getOperatingSystemName() << ")";

const auto consumer = clientInfo.trim();
if (consumer.isNotEmpty())
resolved << " " << consumer;

return resolved;
}

// The resolver the controller will use.
[[nodiscard]] std::shared_ptr<moonbase::device_id_resolver> resolvedDeviceIdResolver() const
{
Expand Down Expand Up @@ -383,13 +420,9 @@ struct ActivationConfig
options.application_version = JucePlugin_VersionString; // a plugin has no JUCEApplication to read it from
#endif

// Identify this client as the JUCE module (appended to the base client's
// User-Agent), with the JUCE version + OS for support/analytics.
juce::String clientInfo;
clientInfo << "moonbase-juce/" << MOONBASE_LICENSING_VERSION
<< " (" << juce::SystemStats::getJUCEVersion()
<< "; " << juce::SystemStats::getOperatingSystemName() << ")";
options.client_info = clientInfo.toStdString();
// Identify this client as the JUCE module, plus whatever a higher-level
// integration appended (see resolvedClientInfo()).
options.client_info = resolvedClientInfo().toStdString();

options.online_validation_grace_period = onlineGracePeriod;
options.online_validation_min_interval = onlineCheckInterval;
Expand Down
41 changes: 39 additions & 2 deletions modules/moonbase_licensing/moonbase/client.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,38 @@ inline std::string version_string()
#endif
}

// Every transport we ship assembles headers into a single line, so a CR or LF in
// a caller-supplied client_info would inject a header and an embedded NUL would
// silently truncate one. Control characters become spaces, runs of whitespace
// collapse, and the result is trimmed and capped, so the User-Agent stays a
// well-formed, bounded token list however many integration layers appended to it.
inline std::string sanitize_client_info(const std::string& value)
{
// Generous for a stack of "Name/Version (comment)" segments, and well under
// the per-line header limits proxies enforce: an oversized User-Agent fails
// as an opaque 400/431 that is undebuggable from the field.
constexpr std::string::size_type max_length = 256;

std::string result;
for (const char character : value) {
const auto byte = static_cast<unsigned char>(character);
if (byte < 0x20 || byte == 0x7F || byte == ' ') {
if (!result.empty() && result.back() != ' ') {
result.push_back(' '); // leading runs drop, interior runs collapse
}
} else {
result.push_back(character);
}
if (result.size() >= max_length) {
break;
}
}
while (!result.empty() && result.back() == ' ') {
result.pop_back();
}
return result;
}

inline std::string request_path(const licensing_options& options)
{
return trim_trailing_slashes(options.endpoint) +
Expand Down Expand Up @@ -74,8 +106,13 @@ inline std::map<std::string, std::string> default_headers(const licensing_option
const std::string& content_type = {})
{
std::string user_agent = "moonbase-cpp/" + version_string();
if (options.client_info && !options.client_info->empty()) {
user_agent += " " + *options.client_info;
if (options.client_info) {
// Sanitise before the emptiness check: a segment that is only whitespace
// or control characters must not leave a dangling separator behind.
const auto client_info = sanitize_client_info(*options.client_info);
if (!client_info.empty()) {
user_agent += " " + client_info;
}
}
std::map<std::string, std::string> headers{
{"Accept", "application/json, application/jwt, text/plain"},
Expand Down
6 changes: 6 additions & 0 deletions modules/moonbase_licensing/moonbase/types.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,12 @@ struct licensing_options {
// Identifies a higher-level integration built on top of the SDK (e.g. the
// JUCE module). Appended to the User-Agent after "moonbase-cpp/<version>" so
// the server can tell which client made the request.
//
// Each layer appends its own space-separated segment rather than replacing
// what is already there, so the header reads outermost-last:
// "moonbase-cpp/4.3.1 moonbase-juce/4.3.1 (JUCE v8.0.4; macOS 15.2) HISE/4.1.0".
// Control characters are stripped and the segment is capped before it reaches
// the header (see detail::sanitize_client_info).
std::optional<std::string> client_info;
std::map<std::string, std::string> metadata;
std::chrono::milliseconds http_connect_timeout{std::chrono::seconds{10}};
Expand Down
52 changes: 52 additions & 0 deletions tests/client_tests.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,58 @@ TEST_CASE("request_activation posts device information and parses response")
CHECK(response.method == activation_method::online);
}

TEST_CASE("client_info is appended to the User-Agent, sanitised and capped")
{
licensing_options options;

SUBCASE("layers read outermost-last after the base segment")
{
options.client_info = "moonbase-juce/9.9 (JUCE v8; TestOS) HISE/4.1.0";
const auto ua = detail::default_headers(options).at("User-Agent");
CHECK(ua.find("moonbase-cpp/") == 0);
CHECK(ua.find("moonbase-juce/9.9") < ua.find("HISE/4.1.0"));
}

SUBCASE("CR/LF cannot inject a second header")
{
options.client_info = "HISE/4.1.0\r\nX-Injected: 1";
const auto ua = detail::default_headers(options).at("User-Agent");
CHECK(ua.find('\r') == std::string::npos);
CHECK(ua.find('\n') == std::string::npos);
CHECK(ua.find("HISE/4.1.0 X-Injected: 1") != std::string::npos);
}

SUBCASE("an embedded NUL cannot truncate the header")
{
options.client_info = std::string("HISE/4.1.0\0hidden", 17);
CHECK(detail::default_headers(options).at("User-Agent").find("HISE/4.1.0 hidden") !=
std::string::npos);
}

SUBCASE("a segment that sanitises away leaves no trailing space")
{
options.client_info = " \r\n\t ";
CHECK(detail::default_headers(options).at("User-Agent") ==
"moonbase-cpp/" + detail::version_string());
}

SUBCASE("whitespace runs collapse to a single separator")
{
options.client_info = " HISE/4.1.0 MyWrapper/2.0 ";
CHECK(detail::default_headers(options).at("User-Agent").find("HISE/4.1.0 MyWrapper/2.0") !=
std::string::npos);
}

SUBCASE("an oversized segment is capped rather than dropped")
{
options.client_info = std::string(1000, 'x');
const auto ua = detail::default_headers(options).at("User-Agent");
CHECK(ua.find("moonbase-cpp/") == 0);
CHECK(ua.size() < 320); // base segment + the 256-char cap
CHECK(ua.find("xxx") != std::string::npos);
}
}

TEST_CASE("request_activation asks for an offline license")
{
client_fixture fixture({
Expand Down
1 change: 1 addition & 0 deletions tests/inventory_tests.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ TEST_CASE("get_release queries the product endpoint with the license token")
CHECK(request.url.find("includeManifests=false") != std::string::npos);
CHECK(request.headers.at("Authorization") == "LicenseToken the-token");
CHECK(request.headers.at("x-mb-client") == "moonbase-cpp");
CHECK(request.headers.at("User-Agent").find("moonbase-juce/9.9") != std::string::npos);
CHECK(request.connect_timeout == std::chrono::milliseconds{1234});
CHECK(request.request_timeout == std::chrono::milliseconds{5678});
}
Expand Down
Loading
Loading