Skip to content

Commit 8ced84e

Browse files
authored
feat(juce): let an integration append its own client info segment (#30)
ActivationConfig gains a `clientInfo` field, appended to the User-Agent after the module's own `moonbase-juce/<version>` segment rather than replacing it, so a framework built on the module (HISE, a wrapper, a white-label host) can identify itself: moonbase-cpp/4.3.1 moonbase-juce/4.3.1 (JUCE v8.0.4; macOS 15.2) HISE/4.1.0 The SDK now sanitises `client_info` when it builds the header: control characters become spaces, whitespace runs collapse, and the segment is capped at 256 characters. Both shipped transports splice headers into a single line, so a CR/LF in a caller-supplied value could otherwise inject a header. The emptiness check runs after sanitising, so a segment that sanitises away leaves no trailing space.
1 parent 45e4730 commit 8ced84e

11 files changed

Lines changed: 285 additions & 11 deletions

File tree

‎docs/core-sdk.md‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,11 +89,29 @@ options.endpoint = "https://demo.moonbase.sh";
8989
options.product_id = "demo-app";
9090
options.public_key = public_key_pem;
9191
options.account_id = "tenant-id"; // optional issuer check
92+
options.client_info = "my-framework/1.2.0"; // optional, see below
9293
options.http_connect_timeout = std::chrono::seconds(10);
9394
options.http_request_timeout = std::chrono::seconds(30);
9495

9596
moonbase::licensing licensing(options);
97+
```
98+
99+
`client_info` identifies a higher-level integration built on top of the SDK (the
100+
JUCE module sets `moonbase-juce/<version> (JUCE …; OS)`, for example). It is
101+
appended to the `User-Agent` after `moonbase-cpp/<version>`, so requests report
102+
every layer, outermost last:
96103
104+
```
105+
User-Agent: moonbase-cpp/4.3.1 my-framework/1.2.0
106+
```
107+
108+
Use product tokens (`Name/Version`, with an optional `(comment)`) and keep it
109+
ASCII. If your code sits on top of another integration that already set it,
110+
append a segment rather than replacing the string. Control characters are
111+
stripped and the value is capped at 256 characters when the header is built, so
112+
a stray newline can never inject a header.
113+
114+
```cpp
97115
auto request = licensing.request_activation();
98116
std::cout << "Open: " << request.browser_url << "\n";
99117

‎docs/juce-module.md‎

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -499,3 +499,34 @@ The collected map flows into `moonbase::licensing_options::metadata` and is sent
499499
SDK's requests. When you don't set `config.applicationVersion`, it auto-fills from
500500
`JucePlugin_VersionString` in a plugin build (or the running app's version otherwise), so
501501
telemetry reports a version without extra wiring.
502+
503+
### Identifying an integration built on the module
504+
505+
Every request carries a layered `User-Agent`: the SDK, then this module, then anything a
506+
higher-level integration adds. Out of the box:
507+
508+
```
509+
User-Agent: moonbase-cpp/4.3.1 moonbase-juce/4.3.1 (JUCE v8.0.4; macOS 15.2)
510+
```
511+
512+
A framework, wrapper or white-label host that embeds the module can add its own segment
513+
with `config.clientInfo`:
514+
515+
```cpp
516+
config.clientInfo << " HISE/4.1.0"; // append, don't assign
517+
```
518+
519+
```
520+
User-Agent: moonbase-cpp/4.3.1 moonbase-juce/4.3.1 (JUCE v8.0.4; macOS 15.2) HISE/4.1.0
521+
```
522+
523+
Your segment is appended *after* the module's own, never in place of it, so support and
524+
analytics still see which module and SDK version ran underneath. Append with `<<` rather
525+
than assigning, so a stack of layers (framework, then a plugin built on it) each keeps its
526+
mark.
527+
528+
Use product tokens (`Name/Version`, with an optional `(comment)`), keep it ASCII, and keep
529+
it short: control characters are stripped and the whole segment is capped at 256 characters
530+
before it reaches the header. Unlike the analytics capture above, this is sent on every
531+
request and is not gated by `config.analytics.enabled`; it identifies the software, not the
532+
machine or the user.

‎include/moonbase/client.hpp‎

Lines changed: 39 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,38 @@ inline std::string version_string()
2929
#endif
3030
}
3131

32+
// Every transport we ship assembles headers into a single line, so a CR or LF in
33+
// a caller-supplied client_info would inject a header and an embedded NUL would
34+
// silently truncate one. Control characters become spaces, runs of whitespace
35+
// collapse, and the result is trimmed and capped, so the User-Agent stays a
36+
// well-formed, bounded token list however many integration layers appended to it.
37+
inline std::string sanitize_client_info(const std::string& value)
38+
{
39+
// Generous for a stack of "Name/Version (comment)" segments, and well under
40+
// the per-line header limits proxies enforce: an oversized User-Agent fails
41+
// as an opaque 400/431 that is undebuggable from the field.
42+
constexpr std::string::size_type max_length = 256;
43+
44+
std::string result;
45+
for (const char character : value) {
46+
const auto byte = static_cast<unsigned char>(character);
47+
if (byte < 0x20 || byte == 0x7F || byte == ' ') {
48+
if (!result.empty() && result.back() != ' ') {
49+
result.push_back(' '); // leading runs drop, interior runs collapse
50+
}
51+
} else {
52+
result.push_back(character);
53+
}
54+
if (result.size() >= max_length) {
55+
break;
56+
}
57+
}
58+
while (!result.empty() && result.back() == ' ') {
59+
result.pop_back();
60+
}
61+
return result;
62+
}
63+
3264
inline std::string request_path(const licensing_options& options)
3365
{
3466
return trim_trailing_slashes(options.endpoint) +
@@ -74,8 +106,13 @@ inline std::map<std::string, std::string> default_headers(const licensing_option
74106
const std::string& content_type = {})
75107
{
76108
std::string user_agent = "moonbase-cpp/" + version_string();
77-
if (options.client_info && !options.client_info->empty()) {
78-
user_agent += " " + *options.client_info;
109+
if (options.client_info) {
110+
// Sanitise before the emptiness check: a segment that is only whitespace
111+
// or control characters must not leave a dangling separator behind.
112+
const auto client_info = sanitize_client_info(*options.client_info);
113+
if (!client_info.empty()) {
114+
user_agent += " " + client_info;
115+
}
79116
}
80117
std::map<std::string, std::string> headers{
81118
{"Accept", "application/json, application/jwt, text/plain"},

‎include/moonbase/types.hpp‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,12 @@ struct licensing_options {
127127
// Identifies a higher-level integration built on top of the SDK (e.g. the
128128
// JUCE module). Appended to the User-Agent after "moonbase-cpp/<version>" so
129129
// the server can tell which client made the request.
130+
//
131+
// Each layer appends its own space-separated segment rather than replacing
132+
// what is already there, so the header reads outermost-last:
133+
// "moonbase-cpp/4.3.1 moonbase-juce/4.3.1 (JUCE v8.0.4; macOS 15.2) HISE/4.1.0".
134+
// Control characters are stripped and the segment is capped before it reaches
135+
// the header (see detail::sanitize_client_info).
130136
std::optional<std::string> client_info;
131137
std::map<std::string, std::string> metadata;
132138
std::chrono::milliseconds http_connect_timeout{std::chrono::seconds{10}};

‎modules/moonbase_licensing/README.md‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -156,6 +156,9 @@ richer gating, and `onActivationChanged` fires whenever it changes.
156156
- **Telemetry** — `config.analytics.enabled = true` attaches JUCE system/host metadata
157157
(OS, CPU, DAW host, plugin format, …) to activation requests; add your own via
158158
`config.metadata` / `config.onCollectMetadata`.
159+
- **Building on top of the module.** A framework or wrapper that embeds it identifies
160+
itself with `config.clientInfo << " HISE/4.1.0"`, which is appended to the `User-Agent`
161+
after the module's own `moonbase-juce/<version>` segment rather than replacing it.
159162

160163
See [`docs/juce-module.md`](../../docs/juce-module.md) for the full guide and
161164
[`examples/juce-native/`](../../examples/juce-native/) for a runnable sample app.

‎modules/moonbase_licensing/juce/ActivationConfig.h‎

Lines changed: 40 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,20 @@ struct ActivationConfig
6161
juce::String accountId; // optional issuer pin
6262
juce::String applicationVersion;
6363

64+
// An extra User-Agent segment identifying the layer built on top of this
65+
// module: a framework, a wrapper, a white-label host, e.g. "HISE/4.1.0".
66+
// Appended after the module's own "moonbase-juce/<version> (...)" segment,
67+
// never in place of it, so support and analytics still see which module
68+
// version ran underneath. Sent on every request (not gated by analytics).
69+
//
70+
// Append rather than assign, so a stack of layers each keeps its mark:
71+
// config.clientInfo << " MyWrapper/2.0";
72+
//
73+
// Use product tokens ("Name/Version", optional "(comment)") and keep it
74+
// ASCII. Control characters are stripped and the segment is capped before it
75+
// reaches the header. Read once, when the controller is constructed.
76+
juce::String clientInfo;
77+
6478
//== Validation / network tuning ==========================================
6579
// How long a license stays valid offline since its last successful online
6680
// validation before it is treated as stale (and the app locks). Default 7 days.
@@ -306,6 +320,29 @@ struct ActivationConfig
306320
return juce::File::getSpecialLocation(juce::File::tempDirectory);
307321
}
308322

323+
// What the module reports as the client in the User-Agent: its own segment
324+
// (module version + JUCE version + OS, for support and analytics), then the
325+
// consumer's clientInfo when set. The base client prefixes
326+
// "moonbase-cpp/<version>", so the wire value reads outermost-last:
327+
//
328+
// moonbase-cpp/4.3.1 moonbase-juce/4.3.1 (JUCE v8.0.4; macOS 15.2) HISE/4.1.0
329+
//
330+
// Trim-and-skip only: the SDK strips control characters and caps the length
331+
// when it builds the header, so the character policy lives in one place.
332+
[[nodiscard]] juce::String resolvedClientInfo() const
333+
{
334+
juce::String resolved;
335+
resolved << "moonbase-juce/" << MOONBASE_LICENSING_VERSION
336+
<< " (" << juce::SystemStats::getJUCEVersion()
337+
<< "; " << juce::SystemStats::getOperatingSystemName() << ")";
338+
339+
const auto consumer = clientInfo.trim();
340+
if (consumer.isNotEmpty())
341+
resolved << " " << consumer;
342+
343+
return resolved;
344+
}
345+
309346
// The resolver the controller will use.
310347
[[nodiscard]] std::shared_ptr<moonbase::device_id_resolver> resolvedDeviceIdResolver() const
311348
{
@@ -383,13 +420,9 @@ struct ActivationConfig
383420
options.application_version = JucePlugin_VersionString; // a plugin has no JUCEApplication to read it from
384421
#endif
385422

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

394427
options.online_validation_grace_period = onlineGracePeriod;
395428
options.online_validation_min_interval = onlineCheckInterval;

‎modules/moonbase_licensing/moonbase/client.hpp‎

Lines changed: 39 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,38 @@ inline std::string version_string()
2929
#endif
3030
}
3131

32+
// Every transport we ship assembles headers into a single line, so a CR or LF in
33+
// a caller-supplied client_info would inject a header and an embedded NUL would
34+
// silently truncate one. Control characters become spaces, runs of whitespace
35+
// collapse, and the result is trimmed and capped, so the User-Agent stays a
36+
// well-formed, bounded token list however many integration layers appended to it.
37+
inline std::string sanitize_client_info(const std::string& value)
38+
{
39+
// Generous for a stack of "Name/Version (comment)" segments, and well under
40+
// the per-line header limits proxies enforce: an oversized User-Agent fails
41+
// as an opaque 400/431 that is undebuggable from the field.
42+
constexpr std::string::size_type max_length = 256;
43+
44+
std::string result;
45+
for (const char character : value) {
46+
const auto byte = static_cast<unsigned char>(character);
47+
if (byte < 0x20 || byte == 0x7F || byte == ' ') {
48+
if (!result.empty() && result.back() != ' ') {
49+
result.push_back(' '); // leading runs drop, interior runs collapse
50+
}
51+
} else {
52+
result.push_back(character);
53+
}
54+
if (result.size() >= max_length) {
55+
break;
56+
}
57+
}
58+
while (!result.empty() && result.back() == ' ') {
59+
result.pop_back();
60+
}
61+
return result;
62+
}
63+
3264
inline std::string request_path(const licensing_options& options)
3365
{
3466
return trim_trailing_slashes(options.endpoint) +
@@ -74,8 +106,13 @@ inline std::map<std::string, std::string> default_headers(const licensing_option
74106
const std::string& content_type = {})
75107
{
76108
std::string user_agent = "moonbase-cpp/" + version_string();
77-
if (options.client_info && !options.client_info->empty()) {
78-
user_agent += " " + *options.client_info;
109+
if (options.client_info) {
110+
// Sanitise before the emptiness check: a segment that is only whitespace
111+
// or control characters must not leave a dangling separator behind.
112+
const auto client_info = sanitize_client_info(*options.client_info);
113+
if (!client_info.empty()) {
114+
user_agent += " " + client_info;
115+
}
79116
}
80117
std::map<std::string, std::string> headers{
81118
{"Accept", "application/json, application/jwt, text/plain"},

‎modules/moonbase_licensing/moonbase/types.hpp‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,12 @@ struct licensing_options {
127127
// Identifies a higher-level integration built on top of the SDK (e.g. the
128128
// JUCE module). Appended to the User-Agent after "moonbase-cpp/<version>" so
129129
// the server can tell which client made the request.
130+
//
131+
// Each layer appends its own space-separated segment rather than replacing
132+
// what is already there, so the header reads outermost-last:
133+
// "moonbase-cpp/4.3.1 moonbase-juce/4.3.1 (JUCE v8.0.4; macOS 15.2) HISE/4.1.0".
134+
// Control characters are stripped and the segment is capped before it reaches
135+
// the header (see detail::sanitize_client_info).
130136
std::optional<std::string> client_info;
131137
std::map<std::string, std::string> metadata;
132138
std::chrono::milliseconds http_connect_timeout{std::chrono::seconds{10}};

‎tests/client_tests.cpp‎

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,58 @@ TEST_CASE("request_activation posts device information and parses response")
9595
CHECK(response.method == activation_method::online);
9696
}
9797

98+
TEST_CASE("client_info is appended to the User-Agent, sanitised and capped")
99+
{
100+
licensing_options options;
101+
102+
SUBCASE("layers read outermost-last after the base segment")
103+
{
104+
options.client_info = "moonbase-juce/9.9 (JUCE v8; TestOS) HISE/4.1.0";
105+
const auto ua = detail::default_headers(options).at("User-Agent");
106+
CHECK(ua.find("moonbase-cpp/") == 0);
107+
CHECK(ua.find("moonbase-juce/9.9") < ua.find("HISE/4.1.0"));
108+
}
109+
110+
SUBCASE("CR/LF cannot inject a second header")
111+
{
112+
options.client_info = "HISE/4.1.0\r\nX-Injected: 1";
113+
const auto ua = detail::default_headers(options).at("User-Agent");
114+
CHECK(ua.find('\r') == std::string::npos);
115+
CHECK(ua.find('\n') == std::string::npos);
116+
CHECK(ua.find("HISE/4.1.0 X-Injected: 1") != std::string::npos);
117+
}
118+
119+
SUBCASE("an embedded NUL cannot truncate the header")
120+
{
121+
options.client_info = std::string("HISE/4.1.0\0hidden", 17);
122+
CHECK(detail::default_headers(options).at("User-Agent").find("HISE/4.1.0 hidden") !=
123+
std::string::npos);
124+
}
125+
126+
SUBCASE("a segment that sanitises away leaves no trailing space")
127+
{
128+
options.client_info = " \r\n\t ";
129+
CHECK(detail::default_headers(options).at("User-Agent") ==
130+
"moonbase-cpp/" + detail::version_string());
131+
}
132+
133+
SUBCASE("whitespace runs collapse to a single separator")
134+
{
135+
options.client_info = " HISE/4.1.0 MyWrapper/2.0 ";
136+
CHECK(detail::default_headers(options).at("User-Agent").find("HISE/4.1.0 MyWrapper/2.0") !=
137+
std::string::npos);
138+
}
139+
140+
SUBCASE("an oversized segment is capped rather than dropped")
141+
{
142+
options.client_info = std::string(1000, 'x');
143+
const auto ua = detail::default_headers(options).at("User-Agent");
144+
CHECK(ua.find("moonbase-cpp/") == 0);
145+
CHECK(ua.size() < 320); // base segment + the 256-char cap
146+
CHECK(ua.find("xxx") != std::string::npos);
147+
}
148+
}
149+
98150
TEST_CASE("request_activation asks for an offline license")
99151
{
100152
client_fixture fixture({

‎tests/inventory_tests.cpp‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ TEST_CASE("get_release queries the product endpoint with the license token")
5252
CHECK(request.url.find("includeManifests=false") != std::string::npos);
5353
CHECK(request.headers.at("Authorization") == "LicenseToken the-token");
5454
CHECK(request.headers.at("x-mb-client") == "moonbase-cpp");
55+
CHECK(request.headers.at("User-Agent").find("moonbase-juce/9.9") != std::string::npos);
5556
CHECK(request.connect_timeout == std::chrono::milliseconds{1234});
5657
CHECK(request.request_timeout == std::chrono::milliseconds{5678});
5758
}

0 commit comments

Comments
 (0)