Skip to content

Commit dbe483a

Browse files
feat(audience-env): explicit environment selection (dev / sandbox / production)
Replaces key-prefix URL derivation with an explicit AudienceEnvironment enum. Studios pick Dev / Sandbox / Production via AudienceConfig.Environment; default is Sandbox so a misconfigured integration cannot accidentally send production traffic — Production is opt-in. API changes: - AudienceEnvironment enum: Dev / Sandbox / Production. - AudienceConfig.Environment field, default Sandbox. - Constants.DevBaseUrl added; Constants.BaseUrl / MessagesUrl / ConsentUrl / DataUrl now take an AudienceEnvironment (the old publishable-key parameter is gone). - HttpTransport constructor accepts AudienceEnvironment (default Sandbox); removes the old key-prefix URL logic. - ImmutableAudience.Init wires config.Environment into HttpTransport; DeleteData and SetConsent thread it through to their control-plane URLs. - ImmutableAudience.CurrentEnvironment public getter returns the last Init's env. Survives Shutdown so a diagnostic HUD does not flicker to Sandbox on teardown; pre-Init returns Sandbox; ResetState restores the Sandbox default. Behavior change worth flagging: integrations using a production- prefixed key that left Environment unset previously hit production via the key-prefix path; they now hit Sandbox until Environment = Production is set explicitly. Test-key integrations already got Sandbox and are unaffected. Tests cover every env → URL mapping, HttpTransport honoring explicit Dev / Production over the Sandbox default, and CurrentEnvironment pre-Init / post-Shutdown / default-Sandbox behaviour. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 2aaf8ca commit dbe483a

9 files changed

Lines changed: 141 additions & 16 deletions

File tree

src/Packages/Audience/Runtime/AudienceConfig.cs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,10 @@ public class AudienceConfig
1010
// Studio API key. Required — Init throws if null.
1111
public string? PublishableKey { get; set; }
1212

13+
// Target backend. Sandbox default prevents accidental production
14+
// traffic; pin to Production explicitly when shipping to players.
15+
public AudienceEnvironment Environment { get; set; } = AudienceEnvironment.Sandbox;
16+
1317
// Initial consent level.
1418
public ConsentLevel Consent { get; set; } = ConsentLevel.None;
1519

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
#nullable enable
2+
3+
namespace Immutable.Audience
4+
{
5+
public enum AudienceEnvironment
6+
{
7+
// Immutable-internal development backend; not intended for studios.
8+
Dev,
9+
Sandbox,
10+
Production,
11+
}
12+
}

src/Packages/Audience/Runtime/Core/Constants.cs

Lines changed: 15 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ namespace Immutable.Audience
44
{
55
internal static class Constants
66
{
7-
internal const string TestKeyPrefix = "pk_imapik-test-";
7+
internal const string DevBaseUrl = "https://api.dev.immutable.com";
88
internal const string SandboxBaseUrl = "https://api.sandbox.immutable.com";
99
internal const string ProductionBaseUrl = "https://api.immutable.com";
1010

@@ -26,14 +26,21 @@ internal static class Constants
2626

2727
internal const string PublishableKeyHeader = "x-immutable-publishable-key";
2828

29-
internal static string MessagesUrl(string? publishableKey) => BaseUrl(publishableKey) + MessagesPath;
30-
internal static string ConsentUrl(string? publishableKey) => BaseUrl(publishableKey) + ConsentPath;
31-
internal static string DataUrl(string? publishableKey) => BaseUrl(publishableKey) + DataPath;
29+
internal static string MessagesUrl(AudienceEnvironment environment) => BaseUrl(environment) + MessagesPath;
30+
internal static string ConsentUrl(AudienceEnvironment environment) => BaseUrl(environment) + ConsentPath;
31+
internal static string DataUrl(AudienceEnvironment environment) => BaseUrl(environment) + DataPath;
3232

33-
internal static string BaseUrl(string? publishableKey) =>
34-
publishableKey != null && publishableKey.StartsWith(TestKeyPrefix)
35-
? SandboxBaseUrl
36-
: ProductionBaseUrl;
33+
internal static string BaseUrl(AudienceEnvironment environment) =>
34+
environment switch
35+
{
36+
AudienceEnvironment.Dev => DevBaseUrl,
37+
AudienceEnvironment.Sandbox => SandboxBaseUrl,
38+
AudienceEnvironment.Production => ProductionBaseUrl,
39+
// Defensive: a future enum addition we forget to wire up
40+
// falls back to Sandbox so accidental production traffic
41+
// is impossible without explicit opt-in.
42+
_ => SandboxBaseUrl,
43+
};
3744
}
3845

3946
// Message type values written to (and read back from) the "type" field.

src/Packages/Audience/Runtime/ImmutableAudience.cs

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,11 @@ public static class ImmutableAudience
6262
// a prior session changed it.
6363
public static ConsentLevel CurrentConsent => _state.Level;
6464

65+
// Caches the last Init's env so a diagnostic HUD does not flicker
66+
// to the Sandbox default when Shutdown clears _config.
67+
public static AudienceEnvironment CurrentEnvironment => _currentEnvironment;
68+
private static volatile AudienceEnvironment _currentEnvironment = AudienceEnvironment.Sandbox;
69+
6570
public static string? UserId => _state.UserId;
6671

6772
// Display-only — Reset and SetConsent(None) wipe it, so it is not
@@ -125,14 +130,15 @@ public static void Init(AudienceConfig config)
125130
}
126131

127132
_config = config;
133+
_currentEnvironment = config.Environment;
128134
Log.Enabled = config.Debug;
129135
// Persisted consent overrides the config default (prior downgrade survives restart).
130136
var initialLevel = ConsentStore.Load(config.PersistentDataPath) ?? config.Consent;
131137
_state = new ConsentState(initialLevel, null);
132138

133139
_store = new DiskStore(config.PersistentDataPath);
134140
_queue = new EventQueue(_store, config.FlushIntervalSeconds, config.FlushSize);
135-
_transport = new HttpTransport(_store, config.PublishableKey, config.OnError, config.HttpHandler);
141+
_transport = new HttpTransport(_store, config.PublishableKey, config.Environment, config.OnError, config.HttpHandler);
136142
_controlClient = config.HttpHandler != null
137143
? new HttpClient(config.HttpHandler, disposeHandler: false)
138144
: new HttpClient();
@@ -388,7 +394,7 @@ public static Task DeleteData(string? userId = null)
388394
query = "anonymousId=" + Uri.EscapeDataString(anonymousId);
389395
}
390396

391-
var url = Constants.DataUrl(config.PublishableKey) + "?" + query;
397+
var url = Constants.DataUrl(config.Environment) + "?" + query;
392398
var onError = config.OnError;
393399
var publishableKey = config.PublishableKey;
394400
var cancellationToken = _shutdownCancellationSource?.Token ?? CancellationToken.None;
@@ -550,7 +556,7 @@ private static void SyncConsentToBackend(AudienceConfig config, ConsentLevel lev
550556
var client = _controlClient;
551557
if (client == null) return;
552558

553-
var url = Constants.ConsentUrl(config.PublishableKey);
559+
var url = Constants.ConsentUrl(config.Environment);
554560
var publishableKey = config.PublishableKey;
555561
var onError = config.OnError;
556562
var cancellationToken = _shutdownCancellationSource?.Token ?? CancellationToken.None;
@@ -765,6 +771,9 @@ internal static void ResetState()
765771
// Defensive: Shutdown nulls _session too, but a future refactor
766772
// that bails before that null must not leak a stale Session.
767773
_session = null;
774+
// Reset the env cache so test isolation and domain-reload
775+
// both start from Sandbox; a subsequent Init overwrites it.
776+
_currentEnvironment = AudienceEnvironment.Sandbox;
768777
Identity.ClearCache();
769778
}
770779
}

src/Packages/Audience/Runtime/Transport/HttpTransport.cs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,18 +28,21 @@ internal sealed class HttpTransport : IDisposable
2828

2929
// store: source of event batches.
3030
// publishableKey: sent as x-immutable-publishable-key on every request.
31+
// environment: which backend to send to. Defaults to Sandbox so
32+
// tests and ad-hoc construction cannot accidentally hit production.
3133
// onError: optional failure callback. Exceptions thrown inside it are caught.
3234
// handler / getUtcNow: test seams; null for production use.
3335
internal HttpTransport(
3436
DiskStore store,
3537
string publishableKey,
38+
AudienceEnvironment environment = AudienceEnvironment.Sandbox,
3639
Action<AudienceError>? onError = null,
3740
HttpMessageHandler? handler = null,
3841
Func<DateTime>? getUtcNow = null)
3942
{
4043
_store = store ?? throw new ArgumentNullException(nameof(store));
4144
_publishableKey = publishableKey ?? throw new ArgumentNullException(nameof(publishableKey));
42-
_url = Constants.MessagesUrl(publishableKey);
45+
_url = Constants.MessagesUrl(environment);
4346
_onError = onError;
4447
// disposeHandler: false so the consumer can reuse their handler
4548
// across Init/Shutdown cycles (matches _controlClient's policy).

src/Packages/Audience/Tests/Runtime/ConsentSyncTests.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ public void SetConsent_FiresPut_WithExpectedBodyShape()
4242
var put = WaitForPut(handler);
4343
var body = JsonReader.DeserializeObject(put.Body);
4444

45-
Assert.AreEqual(Constants.ConsentUrl("pk_imapik-test-key1"), put.Url);
45+
Assert.AreEqual(Constants.ConsentUrl(AudienceEnvironment.Sandbox), put.Url);
4646
Assert.AreEqual("full", body["status"]);
4747
Assert.AreEqual(Constants.ConsentSource, body["source"]);
4848
Assert.IsTrue(body.ContainsKey("anonymousId"));

src/Packages/Audience/Tests/Runtime/ConstantsTests.cs

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,32 @@ namespace Immutable.Audience.Tests
66
[TestFixture]
77
internal class ConstantsTests
88
{
9+
// -----------------------------------------------------------------
10+
// BaseUrl per environment
11+
// -----------------------------------------------------------------
12+
13+
[Test]
14+
public void BaseUrl_Dev_ReturnsDevHost()
15+
{
16+
Assert.AreEqual(Constants.DevBaseUrl, Constants.BaseUrl(AudienceEnvironment.Dev));
17+
}
18+
19+
[Test]
20+
public void BaseUrl_Sandbox_ReturnsSandboxHost()
21+
{
22+
Assert.AreEqual(Constants.SandboxBaseUrl, Constants.BaseUrl(AudienceEnvironment.Sandbox));
23+
}
24+
25+
[Test]
26+
public void BaseUrl_Production_ReturnsProductionHost()
27+
{
28+
Assert.AreEqual(Constants.ProductionBaseUrl, Constants.BaseUrl(AudienceEnvironment.Production));
29+
}
30+
31+
// -----------------------------------------------------------------
32+
// Library version
33+
// -----------------------------------------------------------------
34+
935
[Test]
1036
public void LibraryVersion_MatchesPackageJson()
1137
{

src/Packages/Audience/Tests/Runtime/ImmutableAudienceTests.cs

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,53 @@ public void Initialized_FlipsAroundInitAndShutdown()
8080
"Initialized should flip back to false after Shutdown");
8181
}
8282

83+
[Test]
84+
public void CurrentEnvironment_DefaultsToSandbox()
85+
{
86+
// MakeConfig() leaves Environment unset, which means
87+
// AudienceConfig's Sandbox default applies.
88+
ImmutableAudience.Init(MakeConfig());
89+
Assert.AreEqual(AudienceEnvironment.Sandbox,
90+
ImmutableAudience.CurrentEnvironment);
91+
}
92+
93+
[Test]
94+
public void CurrentEnvironment_ExplicitDev_PassesThrough()
95+
{
96+
var config = MakeConfig();
97+
config.Environment = AudienceEnvironment.Dev;
98+
ImmutableAudience.Init(config);
99+
Assert.AreEqual(AudienceEnvironment.Dev,
100+
ImmutableAudience.CurrentEnvironment);
101+
}
102+
103+
[Test]
104+
public void CurrentEnvironment_BeforeInit_ReturnsSandbox()
105+
{
106+
// Pre-Init there is no config to read. Returning Sandbox
107+
// matches the AudienceConfig default so UI never shows a
108+
// separate "(uninitialised)" sentinel for this row.
109+
Assert.AreEqual(AudienceEnvironment.Sandbox,
110+
ImmutableAudience.CurrentEnvironment);
111+
}
112+
113+
[Test]
114+
public void CurrentEnvironment_SurvivesShutdown()
115+
{
116+
// A diagnostic HUD running in Production must not flicker to
117+
// Sandbox when the SDK tears down. CurrentEnvironment caches
118+
// the last Init's env so it keeps reporting the same value
119+
// after Shutdown clears _config.
120+
var config = MakeConfig();
121+
config.Environment = AudienceEnvironment.Production;
122+
ImmutableAudience.Init(config);
123+
ImmutableAudience.Shutdown();
124+
125+
Assert.AreEqual(AudienceEnvironment.Production,
126+
ImmutableAudience.CurrentEnvironment,
127+
"CurrentEnvironment should retain the last Init's value after Shutdown");
128+
}
129+
83130
[Test]
84131
public void CurrentConsent_ReflectsLatestSetConsent()
85132
{

src/Packages/Audience/Tests/Runtime/Transport/HttpTransportTests.cs

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -123,7 +123,7 @@ public async Task SendBatchAsync_200_SendsPlainJsonPayloadWithoutContentEncoding
123123
#endif
124124

125125
[Test]
126-
public async Task SendBatchAsync_200_UsesCorrectUrlForTestKey()
126+
public async Task SendBatchAsync_DefaultEnvironment_HitsSandbox()
127127
{
128128
_store.Write("{\"type\":\"track\"}");
129129

@@ -138,14 +138,31 @@ public async Task SendBatchAsync_200_UsesCorrectUrlForTestKey()
138138
}
139139

140140
[Test]
141-
public async Task SendBatchAsync_200_UsesCorrectUrlForProdKey()
141+
public async Task SendBatchAsync_ExplicitDev_HitsDev()
142142
{
143143
_store.Write("{\"type\":\"track\"}");
144144

145145
HttpRequestMessage captured = null;
146146
var handler = new MockHandler(HttpStatusCode.OK, "{\"accepted\":1,\"rejected\":0}",
147147
onRequest: req => captured = req);
148-
using var transport = new HttpTransport(_store, "pk_imapik-prodkey", handler: handler);
148+
using var transport = new HttpTransport(_store, "pk_imapik-test-key1",
149+
environment: AudienceEnvironment.Dev, handler: handler);
150+
151+
await transport.SendBatchAsync();
152+
153+
StringAssert.StartsWith(Constants.DevBaseUrl, captured.RequestUri.ToString());
154+
}
155+
156+
[Test]
157+
public async Task SendBatchAsync_ExplicitProduction_HitsProduction()
158+
{
159+
_store.Write("{\"type\":\"track\"}");
160+
161+
HttpRequestMessage captured = null;
162+
var handler = new MockHandler(HttpStatusCode.OK, "{\"accepted\":1,\"rejected\":0}",
163+
onRequest: req => captured = req);
164+
using var transport = new HttpTransport(_store, "pk_imapik-test-key1",
165+
environment: AudienceEnvironment.Production, handler: handler);
149166

150167
await transport.SendBatchAsync();
151168

0 commit comments

Comments
 (0)