diff --git a/src/ZB.MOM.WW.ScadaBridge.Commons/Types/Deployment/DeploymentFetchToken.cs b/src/ZB.MOM.WW.ScadaBridge.Commons/Types/Deployment/DeploymentFetchToken.cs
new file mode 100644
index 00000000..489c3947
--- /dev/null
+++ b/src/ZB.MOM.WW.ScadaBridge.Commons/Types/Deployment/DeploymentFetchToken.cs
@@ -0,0 +1,22 @@
+using System.Security.Cryptography;
+using System.Text;
+
+namespace ZB.MOM.WW.ScadaBridge.Commons.Types.Deployment;
+
+///
+/// Generates and compares the single-purpose, short-TTL token that authorizes a
+/// site's HTTP fetch of one deployment's flattened config. URL-safe; compared in
+/// constant time to avoid timing oracles.
+///
+public static class DeploymentFetchToken
+{
+ /// Generates a URL-safe random token (256 bits of entropy).
+ public static string Generate() =>
+ Convert.ToBase64String(RandomNumberGenerator.GetBytes(32))
+ .Replace('+', '-').Replace('/', '_').TrimEnd('=');
+
+ /// Constant-time string comparison (no early-out on first mismatch).
+ public static bool ConstantTimeEquals(string a, string b) =>
+ CryptographicOperations.FixedTimeEquals(
+ Encoding.UTF8.GetBytes(a), Encoding.UTF8.GetBytes(b));
+}
diff --git a/src/ZB.MOM.WW.ScadaBridge.Communication/CommunicationOptions.cs b/src/ZB.MOM.WW.ScadaBridge.Communication/CommunicationOptions.cs
index 5c4790ac..78c6cc41 100644
--- a/src/ZB.MOM.WW.ScadaBridge.Communication/CommunicationOptions.cs
+++ b/src/ZB.MOM.WW.ScadaBridge.Communication/CommunicationOptions.cs
@@ -58,4 +58,17 @@ public class CommunicationOptions
/// Akka.Remote transport failure detection threshold.
public TimeSpan TransportFailureThreshold { get; set; } = TimeSpan.FromSeconds(15);
+
+ ///
+ /// Base URL (Traefik/LB) the SITE uses to fetch deploy configs from central,
+ /// e.g. "https://central.example:9000". Carried in RefreshDeploymentCommand so
+ /// sites need no new standing config. Empty disables notify-and-fetch fallback.
+ ///
+ public string CentralFetchBaseUrl { get; set; } = "";
+
+ ///
+ /// How long a staged PendingDeployment (and its fetch token) stays valid. Must
+ /// comfortably cover both site nodes' fetches within one deploy window.
+ ///
+ public TimeSpan PendingDeploymentTtl { get; set; } = TimeSpan.FromMinutes(5);
}
diff --git a/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/DeploymentFetchTokenTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/DeploymentFetchTokenTests.cs
new file mode 100644
index 00000000..8f9b3adf
--- /dev/null
+++ b/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/DeploymentFetchTokenTests.cs
@@ -0,0 +1,47 @@
+using ZB.MOM.WW.ScadaBridge.Commons.Types.Deployment;
+
+namespace ZB.MOM.WW.ScadaBridge.Commons.Tests;
+
+public class DeploymentFetchTokenTests
+{
+ [Fact]
+ public void Generate_ReturnsUrlSafeTokenOfAdequateLength()
+ {
+ var token = DeploymentFetchToken.Generate();
+
+ Assert.True(token.Length >= 32, $"Expected length >= 32 but got {token.Length}");
+ Assert.DoesNotContain('+', token);
+ Assert.DoesNotContain('/', token);
+ }
+
+ [Fact]
+ public void Generate_TwoCallsReturnDifferentValues()
+ {
+ var a = DeploymentFetchToken.Generate();
+ var b = DeploymentFetchToken.Generate();
+
+ Assert.NotEqual(a, b);
+ }
+
+ [Fact]
+ public void ConstantTimeEquals_SameToken_ReturnsTrue()
+ {
+ var token = DeploymentFetchToken.Generate();
+
+ Assert.True(DeploymentFetchToken.ConstantTimeEquals(token, token));
+ }
+
+ [Fact]
+ public void ConstantTimeEquals_TokenWithSuffix_ReturnsFalse()
+ {
+ var token = DeploymentFetchToken.Generate();
+
+ Assert.False(DeploymentFetchToken.ConstantTimeEquals(token, token + "x"));
+ }
+
+ [Fact]
+ public void ConstantTimeEquals_BothEmpty_ReturnsTrue()
+ {
+ Assert.True(DeploymentFetchToken.ConstantTimeEquals("", ""));
+ }
+}