diff --git a/CHANGELOG.md b/CHANGELOG.md
index 424482b..172a592 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,13 @@
# Changelog
+## [Unreleased]
+
+### Added
+- **`Cocoar.Configuration.ConfigHub`** — new opt-in ConfigHub delivery provider with bearer-authenticated JSON snapshots, ETag-based conditional refresh, SSE invalidation, reconnect reconciliation, and optional polling fallback. Static and config-aware `FromConfigHub()` rules support endpoint and token rotation without serializing delivery credentials.
+
+### Documentation
+- Added the ConfigHub provider guide, package reference, installation paths, and an updated ConfigHub roadmap that distinguishes the available runtime provider from the separately developed management portal.
+
## [6.1.1] - 2026-08-17
### Fixed
diff --git a/README.md b/README.md
index 461ca00..10795bb 100644
--- a/README.md
+++ b/README.md
@@ -18,6 +18,13 @@ dotnet add package Cocoar.Configuration.AspNetCore # + health endpoints, fea
You only need **one** — each includes everything above it. Requires .NET 9+.
+Optional provider packages add only the integration you need:
+
+```shell
+dotnet add package Cocoar.Configuration.ConfigHub # ConfigHub delivery and live invalidation
+dotnet add package Cocoar.Configuration.Http # Generic remote configuration via HTTP
+```
+
## Quick Start
```csharp
@@ -64,6 +71,7 @@ app.Run();
| Command Line | `.FromCommandLine("--prefix")` | Core |
| Static / Observable | `.FromStaticJson()` / `.FromObservable()` | Core |
| WritableStore (writable overlay) | `.FromStore()` | Core |
+| ConfigHub | `.FromConfigHub(url, token)` | [ConfigHub](https://www.nuget.org/packages/Cocoar.Configuration.ConfigHub) |
| HTTP | `.FromHttp(url)` | [Http](https://www.nuget.org/packages/Cocoar.Configuration.Http) |
| Microsoft IConfiguration | `.FromIConfiguration(config)` | [MicrosoftAdapter](https://www.nuget.org/packages/Cocoar.Configuration.MicrosoftAdapter) |
diff --git a/src/Cocoar.Configuration.ConfigHub/Cocoar.Configuration.ConfigHub.csproj b/src/Cocoar.Configuration.ConfigHub/Cocoar.Configuration.ConfigHub.csproj
new file mode 100644
index 0000000..2df817d
--- /dev/null
+++ b/src/Cocoar.Configuration.ConfigHub/Cocoar.Configuration.ConfigHub.csproj
@@ -0,0 +1,11 @@
+
+
+ true
+ ConfigHub provider for Cocoar.Configuration with authenticated snapshots, ETag validation, Server-Sent Events invalidation, and polling fallback.
+ configuration;confighub;remote-config;sse;server-sent-events;etag;distributed;reactive
+
+
+
+
+
+
diff --git a/src/Cocoar.Configuration.ConfigHub/ConfigHubProvider.cs b/src/Cocoar.Configuration.ConfigHub/ConfigHubProvider.cs
new file mode 100644
index 0000000..d77750b
--- /dev/null
+++ b/src/Cocoar.Configuration.ConfigHub/ConfigHubProvider.cs
@@ -0,0 +1,340 @@
+using System.Diagnostics;
+using System.Net;
+using System.Net.Http.Headers;
+using System.Text;
+using Cocoar.Configuration.Providers.Abstractions;
+
+namespace Cocoar.Configuration.ConfigHub;
+
+///
+/// Loads authoritative JSON snapshots over HTTP and uses SSE only as an invalidation channel.
+///
+public sealed class ConfigHubProvider
+ : ConfigurationProvider, IDisposable
+{
+ private static readonly TimeSpan InitialReconnectDelay = TimeSpan.FromSeconds(1);
+ private static readonly TimeSpan MaxReconnectDelay = TimeSpan.FromSeconds(30);
+
+ private readonly HttpClient _client;
+ private readonly CancellationTokenSource _lifetime = new();
+ private int _disposed;
+
+ ///
+ /// Creates a provider with the specified connection-level options.
+ ///
+ /// Connection-level polling, timeout, and transport options.
+ public ConfigHubProvider(ConfigHubProviderOptions options)
+ : base(options ?? throw new ArgumentNullException(nameof(options)))
+ {
+ _client = options.Handler is null
+ ? new HttpClient()
+ : new HttpClient(options.Handler, disposeHandler: false);
+ }
+
+ ///
+ public override async Task FetchConfigurationBytesAsync(
+ ConfigHubProviderQueryOptions query,
+ CancellationToken ct = default)
+ {
+ ArgumentNullException.ThrowIfNull(query);
+
+ var bytes = await FetchSnapshotAsync(query, useCacheValidator: false, ct).ConfigureAwait(false);
+ return bytes ?? throw new InvalidOperationException(
+ "ConfigHub returned no content for an unconditional snapshot request.");
+ }
+
+ ///
+ public override IObservable ChangesAsBytes(ConfigHubProviderQueryOptions query)
+ {
+ ArgumentNullException.ThrowIfNull(query);
+ return new ChangeObservable(this, query);
+ }
+
+ ///
+ /// Stops active subscriptions and disposes provider-owned HTTP resources.
+ ///
+ public void Dispose()
+ {
+ if (Interlocked.Exchange(ref _disposed, 1) != 0)
+ {
+ return;
+ }
+
+ _lifetime.Cancel();
+ _client.Dispose();
+ _lifetime.Dispose();
+ }
+
+ private async Task FetchSnapshotAsync(
+ ConfigHubProviderQueryOptions query,
+ bool useCacheValidator,
+ CancellationToken ct)
+ {
+ await query.RefreshGate.WaitAsync(ct).ConfigureAwait(false);
+ try
+ {
+ using var request = CreateRequest(query, "application/json");
+
+ var entityTag = useCacheValidator ? query.GetEntityTag(query.Url) : null;
+ if (entityTag is not null && EntityTagHeaderValue.TryParse(entityTag, out var parsedEntityTag))
+ {
+ request.Headers.IfNoneMatch.Add(parsedEntityTag);
+ }
+
+ using var response = await _client.SendAsync(request, ct).ConfigureAwait(false);
+ if (response.StatusCode == HttpStatusCode.NotModified)
+ {
+ if (response.Headers.ETag is { } refreshedEntityTag)
+ {
+ query.SetEntityTag(query.Url, refreshedEntityTag.ToString());
+ }
+
+ return null;
+ }
+
+ response.EnsureSuccessStatusCode();
+ var bytes = await response.Content.ReadAsByteArrayAsync(ct).ConfigureAwait(false);
+ query.SetEntityTag(query.Url, response.Headers.ETag?.ToString());
+ return bytes;
+ }
+ finally
+ {
+ query.RefreshGate.Release();
+ }
+ }
+
+ private static HttpRequestMessage CreateRequest(ConfigHubProviderQueryOptions query, string accept)
+ {
+ var request = new HttpRequestMessage(HttpMethod.Get, query.Url);
+ request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue(accept));
+ request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", query.DeliveryToken);
+ return request;
+ }
+
+ private sealed class ChangeObservable(ConfigHubProvider provider, ConfigHubProviderQueryOptions query)
+ : IObservable
+ {
+ public IDisposable Subscribe(IObserver observer)
+ {
+ ArgumentNullException.ThrowIfNull(observer);
+
+ var cts = CancellationTokenSource.CreateLinkedTokenSource(provider._lifetime.Token);
+ var sink = new SerializedObserver(observer);
+ _ = Task.Run(() => RunAsync(sink, cts.Token), CancellationToken.None);
+ return new Subscription(cts);
+ }
+
+ private async Task RunAsync(SerializedObserver observer, CancellationToken ct)
+ {
+ var tasks = new List
+ {
+ RunSseLoopAsync(observer, ct),
+ };
+
+ if (provider.ProviderOptions.FallbackPollInterval is { } interval)
+ {
+ tasks.Add(RunPollingLoopAsync(observer, interval, ct));
+ }
+
+ try
+ {
+ await Task.WhenAll(tasks).ConfigureAwait(false);
+ }
+ catch (OperationCanceledException) when (ct.IsCancellationRequested)
+ {
+ }
+ catch (Exception ex)
+ {
+ observer.Error(ex);
+ }
+ }
+
+ private async Task RunSseLoopAsync(SerializedObserver observer, CancellationToken ct)
+ {
+ var reconnectDelay = InitialReconnectDelay;
+
+ while (!ct.IsCancellationRequested)
+ {
+ try
+ {
+ await ConnectAndReadAsync(observer, ct).ConfigureAwait(false);
+ reconnectDelay = InitialReconnectDelay;
+ }
+ catch (OperationCanceledException) when (ct.IsCancellationRequested)
+ {
+ return;
+ }
+ catch (Exception ex)
+ {
+ Trace.TraceWarning(
+ "ConfigHub SSE connection to '{0}' failed: {1}: {2}. Retrying in {3:F1}s.",
+ query.Url,
+ ex.GetType().Name,
+ ex.Message,
+ reconnectDelay.TotalSeconds);
+ }
+
+ await Task.Delay(reconnectDelay, ct).ConfigureAwait(false);
+ reconnectDelay = TimeSpan.FromTicks(Math.Min(
+ reconnectDelay.Ticks * 2,
+ MaxReconnectDelay.Ticks));
+ }
+ }
+
+ private async Task ConnectAndReadAsync(SerializedObserver observer, CancellationToken ct)
+ {
+ using var request = ConfigHubProvider.CreateRequest(query, "text/event-stream");
+ using var response = await provider._client.SendAsync(
+ request,
+ HttpCompletionOption.ResponseHeadersRead,
+ ct).ConfigureAwait(false);
+ response.EnsureSuccessStatusCode();
+
+ // The stream never owns configuration state. This reconciliation closes the gap left by
+ // events that may have been published while the connection was down.
+ await FetchAndEmitIfChangedAsync(observer, ct).ConfigureAwait(false);
+
+ await using var stream = await response.Content.ReadAsStreamAsync(ct).ConfigureAwait(false);
+ using var reader = new StreamReader(stream, Encoding.UTF8);
+ var hasDataField = false;
+
+ while (!ct.IsCancellationRequested)
+ {
+ var line = await ReadLineAsync(reader, ct).ConfigureAwait(false);
+ if (line is null)
+ {
+ return;
+ }
+
+ if (line.Length == 0)
+ {
+ if (hasDataField)
+ {
+ await FetchAndEmitIfChangedAsync(observer, ct).ConfigureAwait(false);
+ hasDataField = false;
+ }
+
+ continue;
+ }
+
+ if (line[0] == ':')
+ {
+ continue;
+ }
+
+ var colonIndex = line.IndexOf(':');
+ var field = colonIndex >= 0 ? line[..colonIndex] : line;
+ if (string.Equals(field, "data", StringComparison.Ordinal))
+ {
+ hasDataField = true;
+ }
+ }
+ }
+
+ private async Task ReadLineAsync(StreamReader reader, CancellationToken ct)
+ {
+ if (provider.ProviderOptions.SseReadIdleTimeout is not { } timeout)
+ {
+ return await reader.ReadLineAsync(ct).ConfigureAwait(false);
+ }
+
+ using var readCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
+ readCts.CancelAfter(timeout);
+ try
+ {
+ return await reader.ReadLineAsync(readCts.Token).ConfigureAwait(false);
+ }
+ catch (OperationCanceledException) when (!ct.IsCancellationRequested)
+ {
+ throw new TimeoutException(
+ $"ConfigHub SSE connection received no data for {timeout.TotalSeconds:F0} seconds.");
+ }
+ }
+
+ private async Task RunPollingLoopAsync(
+ SerializedObserver observer,
+ TimeSpan interval,
+ CancellationToken ct)
+ {
+ using var timer = new PeriodicTimer(interval);
+ while (await timer.WaitForNextTickAsync(ct).ConfigureAwait(false))
+ {
+ try
+ {
+ await FetchAndEmitIfChangedAsync(observer, ct).ConfigureAwait(false);
+ }
+ catch (OperationCanceledException) when (ct.IsCancellationRequested)
+ {
+ return;
+ }
+ catch (Exception ex)
+ {
+ Trace.TraceWarning(
+ "ConfigHub fallback poll for '{0}' failed: {1}: {2}.",
+ query.Url,
+ ex.GetType().Name,
+ ex.Message);
+ }
+ }
+ }
+
+ private async Task FetchAndEmitIfChangedAsync(SerializedObserver observer, CancellationToken ct)
+ {
+ var bytes = await provider.FetchSnapshotAsync(query, useCacheValidator: true, ct)
+ .ConfigureAwait(false);
+ if (bytes is not null)
+ {
+ observer.Next(bytes);
+ }
+ }
+ }
+
+ private sealed class SerializedObserver(IObserver observer)
+ {
+ private readonly Lock _gate = new();
+ private bool _stopped;
+
+ public void Next(byte[] value)
+ {
+ lock (_gate)
+ {
+ if (!_stopped)
+ {
+ observer.OnNext(value);
+ }
+ }
+ }
+
+ public void Error(Exception error)
+ {
+ lock (_gate)
+ {
+ if (_stopped)
+ {
+ return;
+ }
+
+ _stopped = true;
+ observer.OnError(error);
+ }
+ }
+ }
+
+ private sealed class Subscription(CancellationTokenSource cts) : IDisposable
+ {
+ public void Dispose()
+ {
+ try
+ {
+ cts.Cancel();
+ }
+ catch (ObjectDisposedException)
+ {
+ }
+ finally
+ {
+ cts.Dispose();
+ }
+ }
+ }
+}
diff --git a/src/Cocoar.Configuration.ConfigHub/ConfigHubProviderOptions.cs b/src/Cocoar.Configuration.ConfigHub/ConfigHubProviderOptions.cs
new file mode 100644
index 0000000..d9b5e5a
--- /dev/null
+++ b/src/Cocoar.Configuration.ConfigHub/ConfigHubProviderOptions.cs
@@ -0,0 +1,68 @@
+using System.Text.Json;
+using System.Text.Json.Serialization;
+using Cocoar.Configuration.Providers.Abstractions;
+
+namespace Cocoar.Configuration.ConfigHub;
+
+///
+/// Connection-level options for the ConfigHub configuration provider.
+///
+public sealed class ConfigHubProviderOptions : IProviderConfiguration
+{
+ private static readonly JsonSerializerOptions ProviderKeyOptions = new()
+ {
+ DefaultIgnoreCondition = JsonIgnoreCondition.Never,
+ PropertyNamingPolicy = null,
+ WriteIndented = false,
+ };
+
+ ///
+ /// Optional interval for a conditional HTTP poll that runs alongside SSE as a safety net.
+ ///
+ public TimeSpan? FallbackPollInterval { get; }
+
+ ///
+ /// Optional maximum time without an SSE line before the connection is re-established.
+ /// ConfigHub sends keep-alive comments, so this may safely be longer than its keep-alive interval.
+ ///
+ public TimeSpan? SseReadIdleTimeout { get; }
+
+ ///
+ /// Optional caller-owned handler used by the provider's HTTP client.
+ ///
+ [JsonIgnore]
+ public HttpMessageHandler? Handler { get; }
+
+ ///
+ /// Creates connection-level options for ConfigHub delivery.
+ ///
+ /// Optional interval for conditional safety-net polling.
+ /// Optional maximum idle time before reconnecting the SSE stream.
+ /// Optional caller-owned HTTP handler. The provider does not dispose it.
+ public ConfigHubProviderOptions(
+ TimeSpan? fallbackPollInterval = null,
+ TimeSpan? sseReadIdleTimeout = null,
+ HttpMessageHandler? handler = null)
+ {
+ ValidatePositive(fallbackPollInterval, nameof(fallbackPollInterval));
+ ValidatePositive(sseReadIdleTimeout, nameof(sseReadIdleTimeout));
+
+ FallbackPollInterval = fallbackPollInterval;
+ SseReadIdleTimeout = sseReadIdleTimeout;
+ Handler = handler;
+ }
+
+ ///
+ public string? GenerateProviderKey()
+ => Handler is null
+ ? JsonSerializer.Serialize(this, ProviderKeyOptions)
+ : null;
+
+ private static void ValidatePositive(TimeSpan? value, string parameterName)
+ {
+ if (value is { } interval && interval <= TimeSpan.Zero)
+ {
+ throw new ArgumentOutOfRangeException(parameterName, "The interval must be greater than zero.");
+ }
+ }
+}
diff --git a/src/Cocoar.Configuration.ConfigHub/ConfigHubProviderQueryOptions.cs b/src/Cocoar.Configuration.ConfigHub/ConfigHubProviderQueryOptions.cs
new file mode 100644
index 0000000..d9c55b8
--- /dev/null
+++ b/src/Cocoar.Configuration.ConfigHub/ConfigHubProviderQueryOptions.cs
@@ -0,0 +1,97 @@
+using System.Net.Http.Headers;
+using System.Security.Cryptography;
+using System.Text;
+using System.Text.Json.Serialization;
+using Cocoar.Configuration.Providers.Abstractions;
+
+namespace Cocoar.Configuration.ConfigHub;
+
+///
+/// Identifies one ConfigHub delivery endpoint and its credential.
+///
+public sealed class ConfigHubProviderQueryOptions : IProviderQuery
+{
+ private readonly Lock _validatorLock = new();
+ private string? _validatorUrl;
+ private string? _entityTag;
+
+ ///
+ /// Absolute ConfigHub delivery URL, for example https://config.example/api/config/my-app.
+ ///
+ public string Url { get; }
+
+ ///
+ /// A non-secret identity used by Cocoar.Configuration to notice token rotation and rebuild the subscription.
+ ///
+ public string CredentialFingerprint { get; }
+
+ ///
+ /// ConfigHub delivery token. It is deliberately excluded from query serialization and logging.
+ ///
+ [JsonIgnore]
+ public string DeliveryToken { get; }
+
+ internal SemaphoreSlim RefreshGate { get; } = new(1, 1);
+
+ ///
+ /// Creates a query for one ConfigHub delivery endpoint.
+ ///
+ /// Absolute HTTP or HTTPS delivery URL.
+ /// Bearer token issued for the delivery endpoint.
+ public ConfigHubProviderQueryOptions(string url, string deliveryToken)
+ {
+ if (!Uri.TryCreate(url, UriKind.Absolute, out var uri) ||
+ (uri.Scheme != Uri.UriSchemeHttp && uri.Scheme != Uri.UriSchemeHttps))
+ {
+ throw new ArgumentException("An absolute HTTP or HTTPS URL is required.", nameof(url));
+ }
+
+ if (string.IsNullOrWhiteSpace(deliveryToken) || !IsValidBearerToken(deliveryToken))
+ {
+ throw new ArgumentException(
+ "A valid ConfigHub delivery token is required.",
+ nameof(deliveryToken));
+ }
+
+ Url = uri.ToString();
+ DeliveryToken = deliveryToken;
+ CredentialFingerprint = Fingerprint(deliveryToken);
+ }
+
+ internal string? GetEntityTag(string resolvedUrl)
+ {
+ lock (_validatorLock)
+ {
+ return string.Equals(_validatorUrl, resolvedUrl, StringComparison.Ordinal)
+ ? _entityTag
+ : null;
+ }
+ }
+
+ internal void SetEntityTag(string resolvedUrl, string? entityTag)
+ {
+ lock (_validatorLock)
+ {
+ _validatorUrl = resolvedUrl;
+ _entityTag = entityTag;
+ }
+ }
+
+ private static string Fingerprint(string value)
+ {
+ var bytes = Encoding.UTF8.GetBytes(value);
+ try
+ {
+ return Convert.ToHexString(SHA256.HashData(bytes));
+ }
+ finally
+ {
+ CryptographicOperations.ZeroMemory(bytes);
+ }
+ }
+
+ private static bool IsValidBearerToken(string value)
+ => AuthenticationHeaderValue.TryParse($"Bearer {value}", out var header) &&
+ string.Equals(header.Scheme, "Bearer", StringComparison.OrdinalIgnoreCase) &&
+ string.Equals(header.Parameter, value, StringComparison.Ordinal);
+}
diff --git a/src/Cocoar.Configuration.ConfigHub/ConfigHubRuleOptions.cs b/src/Cocoar.Configuration.ConfigHub/ConfigHubRuleOptions.cs
new file mode 100644
index 0000000..a4d6435
--- /dev/null
+++ b/src/Cocoar.Configuration.ConfigHub/ConfigHubRuleOptions.cs
@@ -0,0 +1,65 @@
+using System.Text.Json.Serialization;
+
+namespace Cocoar.Configuration.ConfigHub;
+
+///
+/// Combined options for a FromConfigHub configuration rule.
+///
+public sealed class ConfigHubRuleOptions
+{
+ ///
+ /// Absolute HTTP or HTTPS ConfigHub delivery URL.
+ ///
+ public string Url { get; }
+
+ ///
+ /// Bearer token issued for the ConfigHub delivery endpoint.
+ ///
+ [JsonIgnore]
+ public string DeliveryToken { get; }
+
+ ///
+ /// Optional interval for conditional safety-net polling alongside SSE.
+ ///
+ public TimeSpan? FallbackPollInterval { get; }
+
+ ///
+ /// Optional maximum time without an SSE line before reconnecting.
+ ///
+ public TimeSpan? SseReadIdleTimeout { get; }
+
+ ///
+ /// Optional caller-owned handler used for ConfigHub HTTP requests.
+ ///
+ [JsonIgnore]
+ public HttpMessageHandler? Handler { get; }
+
+ ///
+ /// Creates the combined options for a ConfigHub rule.
+ ///
+ /// Absolute HTTP or HTTPS ConfigHub delivery URL.
+ /// Bearer token issued for the delivery endpoint.
+ /// Optional interval for conditional safety-net polling.
+ /// Optional maximum idle time before reconnecting the SSE stream.
+ /// Optional caller-owned HTTP handler. The provider does not dispose it.
+ public ConfigHubRuleOptions(
+ string url,
+ string deliveryToken,
+ TimeSpan? fallbackPollInterval = null,
+ TimeSpan? sseReadIdleTimeout = null,
+ HttpMessageHandler? handler = null)
+ {
+ Url = url;
+ DeliveryToken = deliveryToken;
+ FallbackPollInterval = fallbackPollInterval;
+ SseReadIdleTimeout = sseReadIdleTimeout;
+ Handler = handler;
+ }
+
+ internal ConfigHubProviderOptions ToProviderOptions() => new(
+ FallbackPollInterval,
+ SseReadIdleTimeout,
+ Handler);
+
+ internal ConfigHubProviderQueryOptions ToQueryOptions() => new(Url, DeliveryToken);
+}
diff --git a/src/Cocoar.Configuration.ConfigHub/ConfigHubRulesExtensions.cs b/src/Cocoar.Configuration.ConfigHub/ConfigHubRulesExtensions.cs
new file mode 100644
index 0000000..711f02f
--- /dev/null
+++ b/src/Cocoar.Configuration.ConfigHub/ConfigHubRulesExtensions.cs
@@ -0,0 +1,65 @@
+using Cocoar.Configuration.Core;
+using Cocoar.Configuration.Fluent;
+
+namespace Cocoar.Configuration.ConfigHub;
+
+///
+/// Adds ConfigHub delivery sources to typed configuration rules.
+///
+public static class ConfigHubRulesExtensions
+{
+ ///
+ /// Adds a ConfigHub-backed configuration layer. JSON snapshots remain the source of truth;
+ /// SSE is used only to invalidate the current snapshot.
+ ///
+ /// The typed rule builder.
+ /// Absolute HTTP or HTTPS ConfigHub delivery URL.
+ /// Bearer token issued for the delivery endpoint.
+ /// Optional interval for conditional safety-net polling.
+ /// Optional maximum idle time before reconnecting the SSE stream.
+ /// Optional caller-owned HTTP handler. The provider does not dispose it.
+ public static ProviderRuleBuilder
+ FromConfigHub(
+ this TypedProviderBuilder builder,
+ string url,
+ string deliveryToken,
+ TimeSpan? fallbackPollInterval = null,
+ TimeSpan? sseReadIdleTimeout = null,
+ HttpMessageHandler? handler = null)
+ where T : class
+ {
+ ArgumentNullException.ThrowIfNull(builder);
+
+ var options = new ConfigHubRuleOptions(
+ url,
+ deliveryToken,
+ fallbackPollInterval,
+ sseReadIdleTimeout,
+ handler);
+
+ return new(
+ _ => options.ToProviderOptions(),
+ _ => options.ToQueryOptions(),
+ typeof(T));
+ }
+
+ ///
+ /// Adds a ConfigHub-backed configuration layer whose endpoint or token depends on earlier rules.
+ ///
+ /// The typed rule builder.
+ /// Builds ConfigHub options from the current configuration pass.
+ public static ProviderRuleBuilder
+ FromConfigHub(
+ this TypedProviderBuilder builder,
+ Func optionsFactory)
+ where T : class
+ {
+ ArgumentNullException.ThrowIfNull(builder);
+ ArgumentNullException.ThrowIfNull(optionsFactory);
+
+ return new(
+ accessor => optionsFactory(accessor).ToProviderOptions(),
+ accessor => optionsFactory(accessor).ToQueryOptions(),
+ typeof(T));
+ }
+}
diff --git a/src/Cocoar.Configuration.slnx b/src/Cocoar.Configuration.slnx
index 57d5aaf..4c7bad0 100644
--- a/src/Cocoar.Configuration.slnx
+++ b/src/Cocoar.Configuration.slnx
@@ -11,6 +11,7 @@
+
@@ -40,6 +41,7 @@
+
diff --git a/src/tests/Cocoar.Configuration.ConfigHub.Tests/Cocoar.Configuration.ConfigHub.Tests.csproj b/src/tests/Cocoar.Configuration.ConfigHub.Tests/Cocoar.Configuration.ConfigHub.Tests.csproj
new file mode 100644
index 0000000..e113ddd
--- /dev/null
+++ b/src/tests/Cocoar.Configuration.ConfigHub.Tests/Cocoar.Configuration.ConfigHub.Tests.csproj
@@ -0,0 +1,24 @@
+
+
+ true
+ net10.0
+
+
+
+
+
+
+
+ all
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/tests/Cocoar.Configuration.ConfigHub.Tests/ConfigHubOptionsTests.cs b/src/tests/Cocoar.Configuration.ConfigHub.Tests/ConfigHubOptionsTests.cs
new file mode 100644
index 0000000..cff3e4e
--- /dev/null
+++ b/src/tests/Cocoar.Configuration.ConfigHub.Tests/ConfigHubOptionsTests.cs
@@ -0,0 +1,125 @@
+using System.Text.Json;
+
+namespace Cocoar.Configuration.ConfigHub.Tests;
+
+public sealed class ConfigHubOptionsTests
+{
+ [Theory]
+ [InlineData("config.example/api/config/orders")]
+ [InlineData("ftp://config.example/api/config/orders")]
+ [InlineData("")]
+ public void QueryRequiresAbsoluteHttpEndpoint(string url)
+ {
+ var exception = Assert.Throws(
+ () => new ConfigHubProviderQueryOptions(url, "chcfg_valid"));
+
+ Assert.Equal("url", exception.ParamName);
+ }
+
+ [Theory]
+ [InlineData("")]
+ [InlineData(" ")]
+ [InlineData("\t")]
+ public void QueryRequiresDeliveryToken(string deliveryToken)
+ {
+ var exception = Assert.Throws(
+ () => new ConfigHubProviderQueryOptions(
+ "https://config.example/api/config/orders",
+ deliveryToken));
+
+ Assert.Equal("deliveryToken", exception.ParamName);
+ }
+
+ [Fact]
+ public void InvalidBearerTokenIsRejectedWithoutEchoingCredential()
+ {
+ const string deliveryToken = "chcfg_secret\r\nX-Injected: value";
+
+ var exception = Assert.Throws(
+ () => new ConfigHubProviderQueryOptions(
+ "https://config.example/api/config/orders",
+ deliveryToken));
+
+ Assert.DoesNotContain(deliveryToken, exception.ToString(), StringComparison.Ordinal);
+ }
+
+ [Theory]
+ [InlineData(0)]
+ [InlineData(-1)]
+ public void ProviderOptionsRequirePositiveIntervals(int milliseconds)
+ {
+ var interval = TimeSpan.FromMilliseconds(milliseconds);
+
+ Assert.Throws(
+ () => new ConfigHubProviderOptions(fallbackPollInterval: interval));
+ Assert.Throws(
+ () => new ConfigHubProviderOptions(sseReadIdleTimeout: interval));
+ }
+
+ [Fact]
+ public void CredentialRotationChangesQueryIdentityWithoutSerializingToken()
+ {
+ var first = new ConfigHubProviderQueryOptions(
+ "https://config.example/api/config/orders",
+ "chcfg_first");
+ var rotated = new ConfigHubProviderQueryOptions(
+ "https://config.example/api/config/orders",
+ "chcfg_rotated");
+
+ var firstJson = JsonSerializer.Serialize(first);
+ var rotatedJson = JsonSerializer.Serialize(rotated);
+
+ Assert.NotEqual(first.CredentialFingerprint, rotated.CredentialFingerprint);
+ Assert.NotEqual(firstJson, rotatedJson);
+ Assert.DoesNotContain("chcfg_first", firstJson, StringComparison.Ordinal);
+ Assert.DoesNotContain("chcfg_rotated", rotatedJson, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public void RuleOptionsSerializationExcludesSecretsAndHandler()
+ {
+ using var handler = new HttpClientHandler();
+ var options = new ConfigHubRuleOptions(
+ "https://config.example/api/config/orders",
+ "chcfg_secret",
+ handler: handler);
+
+ var json = JsonSerializer.Serialize(options);
+
+ Assert.DoesNotContain("chcfg_secret", json, StringComparison.Ordinal);
+ Assert.DoesNotContain("Handler", json, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public void CustomHandlerDisablesProviderSharingAndRemainsCallerOwned()
+ {
+ var handler = new TrackingHandler();
+ var options = new ConfigHubProviderOptions(handler: handler);
+
+ Assert.Null(options.GenerateProviderKey());
+
+ using (var provider = new ConfigHubProvider(options))
+ {
+ }
+
+ Assert.False(handler.IsDisposed);
+ handler.Dispose();
+ Assert.True(handler.IsDisposed);
+ }
+
+ private sealed class TrackingHandler : HttpMessageHandler
+ {
+ public bool IsDisposed { get; private set; }
+
+ protected override Task SendAsync(
+ HttpRequestMessage request,
+ CancellationToken cancellationToken)
+ => throw new NotSupportedException();
+
+ protected override void Dispose(bool disposing)
+ {
+ IsDisposed = true;
+ base.Dispose(disposing);
+ }
+ }
+}
diff --git a/src/tests/Cocoar.Configuration.ConfigHub.Tests/ConfigHubProviderTests.cs b/src/tests/Cocoar.Configuration.ConfigHub.Tests/ConfigHubProviderTests.cs
new file mode 100644
index 0000000..f6c2417
--- /dev/null
+++ b/src/tests/Cocoar.Configuration.ConfigHub.Tests/ConfigHubProviderTests.cs
@@ -0,0 +1,280 @@
+using System.Net;
+using System.Net.Http.Headers;
+using System.Text;
+
+namespace Cocoar.Configuration.ConfigHub.Tests;
+
+public sealed class ConfigHubProviderTests
+{
+ [Fact]
+ public async Task InvalidationEventRefetchesSnapshotConditionallyAndIgnoresEventPayload()
+ {
+ var handler = new ProtocolHandler();
+ using var provider = new ConfigHubProvider(new ConfigHubProviderOptions(handler: handler));
+ var query = new ConfigHubProviderQueryOptions(
+ "https://config.example/api/config/orders",
+ "chcfg_secret");
+
+ var initial = await provider.FetchConfigurationBytesAsync(query, CancellationToken.None);
+ Assert.Equal("{\"version\":1}", Encoding.UTF8.GetString(initial));
+
+ var update = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ using var subscription = provider.ChangesAsBytes(query).Subscribe(new TestObserver(
+ bytes => update.TrySetResult(Encoding.UTF8.GetString(bytes)),
+ error => update.TrySetException(error)));
+
+ var completed = await Task.WhenAny(
+ update.Task,
+ Task.Delay(TimeSpan.FromSeconds(3), CancellationToken.None));
+
+ Assert.Same(update.Task, completed);
+ Assert.Equal("{\"version\":2}", await update.Task);
+ subscription.Dispose();
+ Assert.Equal(3, handler.SnapshotRequests);
+ Assert.All(handler.AuthorizationSchemes, scheme => Assert.Equal("Bearer", scheme));
+ Assert.All(handler.AuthorizationParameters, token => Assert.Equal("chcfg_secret", token));
+ Assert.Equal(["\"v1\"", "\"v1\""], handler.IfNoneMatchValues);
+ }
+
+ [Fact]
+ public void QuerySerializationIdentityDoesNotExposeDeliveryToken()
+ {
+ var query = new ConfigHubProviderQueryOptions(
+ "https://config.example/api/config/orders",
+ "chcfg_do-not-serialize");
+
+ var json = System.Text.Json.JsonSerializer.Serialize(query);
+
+ Assert.DoesNotContain("chcfg_do-not-serialize", json, StringComparison.Ordinal);
+ Assert.Contains(query.CredentialFingerprint, json, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public async Task FallbackPollRefreshesSnapshotWhileSseConnectionRemainsOpen()
+ {
+ using var handler = new ResilienceHandler();
+ using var provider = new ConfigHubProvider(new ConfigHubProviderOptions(
+ fallbackPollInterval: TimeSpan.FromMilliseconds(50),
+ handler: handler));
+ var query = new ConfigHubProviderQueryOptions(
+ "https://config.example/api/config/orders",
+ "chcfg_secret");
+
+ await provider.FetchConfigurationBytesAsync(query, CancellationToken.None);
+
+ var update = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ using var subscription = provider.ChangesAsBytes(query).Subscribe(new TestObserver(
+ bytes => update.TrySetResult(Encoding.UTF8.GetString(bytes)),
+ error => update.TrySetException(error)));
+
+ var completed = await Task.WhenAny(update.Task, Task.Delay(TimeSpan.FromSeconds(5)));
+
+ Assert.Same(update.Task, completed);
+ Assert.Equal("{\"version\":2}", await update.Task);
+ Assert.True(handler.SseRequests >= 1);
+ Assert.True(handler.ConditionalSnapshotRequests >= 2);
+ }
+
+ [Fact]
+ public async Task IdleSseReconnectReconcilesSnapshotBeforeReadingNextEvent()
+ {
+ using var handler = new ResilienceHandler();
+ using var provider = new ConfigHubProvider(new ConfigHubProviderOptions(
+ sseReadIdleTimeout: TimeSpan.FromMilliseconds(50),
+ handler: handler));
+ var query = new ConfigHubProviderQueryOptions(
+ "https://config.example/api/config/orders",
+ "chcfg_secret");
+
+ await provider.FetchConfigurationBytesAsync(query, CancellationToken.None);
+
+ var update = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ using var subscription = provider.ChangesAsBytes(query).Subscribe(new TestObserver(
+ bytes => update.TrySetResult(Encoding.UTF8.GetString(bytes)),
+ error => update.TrySetException(error)));
+
+ var completed = await Task.WhenAny(update.Task, Task.Delay(TimeSpan.FromSeconds(5)));
+
+ Assert.Same(update.Task, completed);
+ Assert.Equal("{\"version\":2}", await update.Task);
+ Assert.True(handler.SseRequests >= 2);
+ Assert.True(handler.ConditionalSnapshotRequests >= 2);
+ }
+
+ private sealed class ProtocolHandler : HttpMessageHandler
+ {
+ private int _snapshotRequests;
+
+ public int SnapshotRequests => Volatile.Read(ref _snapshotRequests);
+ public List AuthorizationSchemes { get; } = [];
+ public List AuthorizationParameters { get; } = [];
+ public List IfNoneMatchValues { get; } = [];
+
+ protected override Task SendAsync(
+ HttpRequestMessage request,
+ CancellationToken cancellationToken)
+ {
+ lock (AuthorizationSchemes)
+ {
+ AuthorizationSchemes.Add(request.Headers.Authorization?.Scheme);
+ AuthorizationParameters.Add(request.Headers.Authorization?.Parameter);
+ }
+
+ if (request.Headers.Accept.Any(value => value.MediaType == "text/event-stream"))
+ {
+ var body = new MemoryStream(Encoding.UTF8.GetBytes(
+ "event: config-changed\n" +
+ "data: this payload is deliberately not JSON\n\n"));
+ return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
+ {
+ Content = new StreamContent(body)
+ {
+ Headers = { ContentType = new MediaTypeHeaderValue("text/event-stream") },
+ },
+ });
+ }
+
+ var requestNumber = Interlocked.Increment(ref _snapshotRequests);
+ var validator = request.Headers.IfNoneMatch.SingleOrDefault()?.ToString();
+ if (validator is not null)
+ {
+ lock (IfNoneMatchValues)
+ {
+ IfNoneMatchValues.Add(validator);
+ }
+ }
+
+ return Task.FromResult(requestNumber switch
+ {
+ 1 => JsonResponse("{\"version\":1}", "\"v1\""),
+ 2 => NotModified("\"v1\""),
+ _ => JsonResponse("{\"version\":2}", "\"v2\""),
+ });
+ }
+
+ private static HttpResponseMessage JsonResponse(string json, string entityTag)
+ {
+ var response = new HttpResponseMessage(HttpStatusCode.OK)
+ {
+ Content = new StringContent(json, Encoding.UTF8, "application/json"),
+ };
+ response.Headers.ETag = new EntityTagHeaderValue(entityTag);
+ return response;
+ }
+
+ private static HttpResponseMessage NotModified(string entityTag)
+ {
+ var response = new HttpResponseMessage(HttpStatusCode.NotModified);
+ response.Headers.ETag = new EntityTagHeaderValue(entityTag);
+ return response;
+ }
+ }
+
+ private sealed class TestObserver(Action onNext, Action onError) : IObserver
+ {
+ public void OnNext(byte[] value) => onNext(value);
+ public void OnError(Exception error) => onError(error);
+ public void OnCompleted() { }
+ }
+
+ private sealed class ResilienceHandler : HttpMessageHandler
+ {
+ private int _conditionalSnapshotRequests;
+ private int _sseRequests;
+
+ public int ConditionalSnapshotRequests => Volatile.Read(ref _conditionalSnapshotRequests);
+ public int SseRequests => Volatile.Read(ref _sseRequests);
+
+ protected override Task SendAsync(
+ HttpRequestMessage request,
+ CancellationToken cancellationToken)
+ {
+ if (request.Headers.Accept.Any(value => value.MediaType == "text/event-stream"))
+ {
+ Interlocked.Increment(ref _sseRequests);
+ var response = new HttpResponseMessage(HttpStatusCode.OK)
+ {
+ Content = new StreamContent(new BlockingReadStream()),
+ };
+ response.Content.Headers.ContentType = new MediaTypeHeaderValue("text/event-stream");
+ return Task.FromResult(response);
+ }
+
+ var validator = request.Headers.IfNoneMatch.SingleOrDefault()?.ToString();
+ if (validator is null)
+ {
+ return Task.FromResult(CreateJsonResponse("{\"version\":1}", "\"v1\""));
+ }
+
+ var conditionalRequest = Interlocked.Increment(ref _conditionalSnapshotRequests);
+ return Task.FromResult(conditionalRequest == 1
+ ? CreateNotModifiedResponse("\"v1\"")
+ : CreateJsonResponse("{\"version\":2}", "\"v2\""));
+ }
+
+ private static HttpResponseMessage CreateJsonResponse(string json, string entityTag)
+ {
+ var response = new HttpResponseMessage(HttpStatusCode.OK)
+ {
+ Content = new StringContent(json, Encoding.UTF8, "application/json"),
+ };
+ response.Headers.ETag = new EntityTagHeaderValue(entityTag);
+ return response;
+ }
+
+ private static HttpResponseMessage CreateNotModifiedResponse(string entityTag)
+ {
+ var response = new HttpResponseMessage(HttpStatusCode.NotModified);
+ response.Headers.ETag = new EntityTagHeaderValue(entityTag);
+ return response;
+ }
+ }
+
+ private sealed class BlockingReadStream : Stream
+ {
+ public override bool CanRead => true;
+ public override bool CanSeek => false;
+ public override bool CanWrite => false;
+ public override long Length => throw new NotSupportedException();
+
+ public override long Position
+ {
+ get => throw new NotSupportedException();
+ set => throw new NotSupportedException();
+ }
+
+ public override void Flush()
+ {
+ }
+
+ public override int Read(byte[] buffer, int offset, int count)
+ => throw new NotSupportedException();
+
+ public override async Task ReadAsync(
+ byte[] buffer,
+ int offset,
+ int count,
+ CancellationToken cancellationToken)
+ {
+ await Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken);
+ return 0;
+ }
+
+ public override async ValueTask ReadAsync(
+ Memory buffer,
+ CancellationToken cancellationToken = default)
+ {
+ await Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken);
+ return 0;
+ }
+
+ public override long Seek(long offset, SeekOrigin origin)
+ => throw new NotSupportedException();
+
+ public override void SetLength(long value)
+ => throw new NotSupportedException();
+
+ public override void Write(byte[] buffer, int offset, int count)
+ => throw new NotSupportedException();
+ }
+}
diff --git a/src/tests/Cocoar.Configuration.ConfigHub.Tests/DynamicFromConfigHubTests.cs b/src/tests/Cocoar.Configuration.ConfigHub.Tests/DynamicFromConfigHubTests.cs
new file mode 100644
index 0000000..4f1d3d9
--- /dev/null
+++ b/src/tests/Cocoar.Configuration.ConfigHub.Tests/DynamicFromConfigHubTests.cs
@@ -0,0 +1,170 @@
+using System.Net;
+using System.Net.Http.Headers;
+using Cocoar.Configuration.Core;
+using Cocoar.Configuration.Fluent;
+using Cocoar.Configuration.Providers;
+
+namespace Cocoar.Configuration.ConfigHub.Tests;
+
+public sealed class DynamicFromConfigHubTests
+{
+ [Fact]
+ public async Task DynamicRuleSwitchesSnapshotUrlWhenItsSourceChanges()
+ {
+ using var source = new BehaviorSource("""{ "Region": "us" }""");
+ using var handler = new RegionRoutingHandler();
+
+ using var manager = ConfigManager.Create(configuration => configuration
+ .UseConfiguration(rules =>
+ [
+ rules.For().FromObservable(source),
+ rules.For().FromConfigHub(accessor => new ConfigHubRuleOptions(
+ $"https://config.example/{accessor.GetConfig()!.Region}/config",
+ "chcfg_dynamic-test",
+ handler: handler)),
+ ])
+ .UseDebounce(25));
+
+ Assert.Equal("US", manager.GetConfig()!.Value);
+
+ source.OnNext("""{ "Region": "eu" }""");
+
+ var deadline = DateTime.UtcNow.AddSeconds(5);
+ while (DateTime.UtcNow < deadline && manager.GetConfig()!.Value != "EU")
+ {
+ await Task.Delay(25, CancellationToken.None);
+ }
+
+ Assert.Equal("eu", manager.GetConfig()!.Region);
+ Assert.Equal("EU", manager.GetConfig()!.Value);
+ Assert.Contains(handler.SnapshotPaths, path => path.Contains("/eu/", StringComparison.Ordinal));
+ }
+
+ private sealed class RegionSettings
+ {
+ public string Region { get; set; } = string.Empty;
+ }
+
+ private sealed class RemoteSettings
+ {
+ public string Value { get; set; } = string.Empty;
+ }
+
+ private sealed class RegionRoutingHandler : HttpMessageHandler
+ {
+ private readonly Lock _gate = new();
+ private readonly List _snapshotPaths = [];
+
+ public IReadOnlyList SnapshotPaths
+ {
+ get
+ {
+ lock (_gate)
+ {
+ return _snapshotPaths.ToArray();
+ }
+ }
+ }
+
+ protected override Task SendAsync(
+ HttpRequestMessage request,
+ CancellationToken cancellationToken)
+ {
+ if (request.Headers.Accept.Any(value => value.MediaType == "text/event-stream"))
+ {
+ var stream = new HttpResponseMessage(HttpStatusCode.OK)
+ {
+ Content = new StringContent(": connected\n\n"),
+ };
+ stream.Content.Headers.ContentType = new MediaTypeHeaderValue("text/event-stream");
+ return Task.FromResult(stream);
+ }
+
+ var path = request.RequestUri!.AbsolutePath;
+ lock (_gate)
+ {
+ _snapshotPaths.Add(path);
+ }
+
+ var value = path.Contains("/eu/", StringComparison.Ordinal) ? "EU" : "US";
+ return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
+ {
+ Content = new StringContent($$"""{ "Value": "{{value}}" }"""),
+ });
+ }
+ }
+
+ private sealed class BehaviorSource(T initialValue) : IObservable, IDisposable
+ {
+ private readonly Lock _gate = new();
+ private readonly List> _observers = [];
+ private T _value = initialValue;
+ private bool _disposed;
+
+ public IDisposable Subscribe(IObserver observer)
+ {
+ ArgumentNullException.ThrowIfNull(observer);
+
+ lock (_gate)
+ {
+ ObjectDisposedException.ThrowIf(_disposed, this);
+ _observers.Add(observer);
+ observer.OnNext(_value);
+ }
+
+ return new ObserverSubscription(this, observer);
+ }
+
+ public void OnNext(T value)
+ {
+ IObserver[] observers;
+ lock (_gate)
+ {
+ ObjectDisposedException.ThrowIf(_disposed, this);
+ _value = value;
+ observers = _observers.ToArray();
+ }
+
+ foreach (var observer in observers)
+ {
+ observer.OnNext(value);
+ }
+ }
+
+ public void Dispose()
+ {
+ IObserver[] observers;
+ lock (_gate)
+ {
+ if (_disposed)
+ {
+ return;
+ }
+
+ _disposed = true;
+ observers = _observers.ToArray();
+ _observers.Clear();
+ }
+
+ foreach (var observer in observers)
+ {
+ observer.OnCompleted();
+ }
+ }
+
+ private void Unsubscribe(IObserver observer)
+ {
+ lock (_gate)
+ {
+ _observers.Remove(observer);
+ }
+ }
+
+ private sealed class ObserverSubscription(
+ BehaviorSource source,
+ IObserver observer) : IDisposable
+ {
+ public void Dispose() => source.Unsubscribe(observer);
+ }
+ }
+}
diff --git a/src/tests/Cocoar.Configuration.ConfigHub.Tests/RequiredConfigHubTests.cs b/src/tests/Cocoar.Configuration.ConfigHub.Tests/RequiredConfigHubTests.cs
new file mode 100644
index 0000000..044344f
--- /dev/null
+++ b/src/tests/Cocoar.Configuration.ConfigHub.Tests/RequiredConfigHubTests.cs
@@ -0,0 +1,53 @@
+using System.Net;
+using System.Net.Http.Headers;
+using Cocoar.Configuration.Core;
+using Cocoar.Configuration.Fluent;
+using Cocoar.Configuration.Providers;
+
+namespace Cocoar.Configuration.ConfigHub.Tests;
+
+public sealed class RequiredConfigHubTests
+{
+ [Fact]
+ public void RequiredRuleRejectsInvalidDeliveryCredentialsDuringStartup()
+ {
+ using var handler = new UnauthorizedHandler();
+
+ var exception = Assert.ThrowsAny(() =>
+ {
+ using var manager = ConfigManager.Create(configuration => configuration
+ .UseConfiguration(rules =>
+ [
+ rules.For().FromStaticJson("""{ "Value": "local" }"""),
+ rules.For().FromConfigHub(
+ "https://config.example/api/config/demo-app",
+ "chcfg_wrong",
+ handler: handler)
+ .Required(),
+ ]));
+ });
+
+ Assert.Contains("401", exception.ToString(), StringComparison.Ordinal);
+ Assert.DoesNotContain("chcfg_wrong", exception.ToString(), StringComparison.Ordinal);
+ Assert.Equal("Bearer", handler.Authorization?.Scheme);
+ Assert.Equal("chcfg_wrong", handler.Authorization?.Parameter);
+ }
+
+ private sealed class RemoteSettings
+ {
+ public string Value { get; set; } = string.Empty;
+ }
+
+ private sealed class UnauthorizedHandler : HttpMessageHandler
+ {
+ public AuthenticationHeaderValue? Authorization { get; private set; }
+
+ protected override Task SendAsync(
+ HttpRequestMessage request,
+ CancellationToken cancellationToken)
+ {
+ Authorization = request.Headers.Authorization;
+ return Task.FromResult(new HttpResponseMessage(HttpStatusCode.Unauthorized));
+ }
+ }
+}
diff --git a/website/.vitepress/config.ts b/website/.vitepress/config.ts
index 994885c..b1a470f 100644
--- a/website/.vitepress/config.ts
+++ b/website/.vitepress/config.ts
@@ -68,6 +68,7 @@ export default defineConfig({
{ text: 'INI', link: '/guide/providers/ini' },
{ text: 'Environment Variables', link: '/guide/providers/environment' },
{ text: 'Command Line', link: '/guide/providers/command-line' },
+ { text: 'ConfigHub', link: '/guide/providers/confighub' },
{ text: 'HTTP Polling', link: '/guide/providers/http-polling' },
{ text: 'Microsoft IConfiguration', link: '/guide/providers/microsoft-adapter' },
{ text: 'Static & Observable', link: '/guide/providers/static-observable' },
diff --git a/website/changelog.md b/website/changelog.md
index 6230c3f..ca6291d 100644
--- a/website/changelog.md
+++ b/website/changelog.md
@@ -1,5 +1,13 @@
# Changelog
+## [Unreleased]
+
+### Added
+- **`Cocoar.Configuration.ConfigHub`** — new opt-in ConfigHub delivery provider with bearer-authenticated JSON snapshots, ETag-based conditional refresh, SSE invalidation, reconnect reconciliation, and optional polling fallback. Static and config-aware `FromConfigHub()` rules support endpoint and token rotation without serializing delivery credentials.
+
+### Documentation
+- Added the ConfigHub provider guide, package reference, installation paths, and an updated ConfigHub roadmap that distinguishes the available runtime provider from the separately developed management portal.
+
## [6.1.1] — 2026-08-17
### Fixed
diff --git a/website/guide/getting-started.md b/website/guide/getting-started.md
index 9b9878b..7df8222 100644
--- a/website/guide/getting-started.md
+++ b/website/guide/getting-started.md
@@ -19,6 +19,7 @@ You only need **one** of these — install the highest one you need.
Optional packages for additional providers:
```shell
+dotnet add package Cocoar.Configuration.ConfigHub # ConfigHub delivery + live invalidation
dotnet add package Cocoar.Configuration.Http # Remote config via HTTP
dotnet add package Cocoar.Configuration.MicrosoftAdapter # Bridge existing IConfiguration
```
diff --git a/website/guide/providers/confighub.md b/website/guide/providers/confighub.md
new file mode 100644
index 0000000..a174d35
--- /dev/null
+++ b/website/guide/providers/confighub.md
@@ -0,0 +1,99 @@
+---
+description: Cocoar.Configuration.ConfigHub provider — authenticated snapshots, ETag validation, SSE invalidation, reconnect reconciliation, polling fallback, and dynamic endpoints
+---
+
+# ConfigHub Provider
+
+`Cocoar.Configuration.ConfigHub` connects an application to a ConfigHub delivery endpoint. It is a ConfigHub-specific integration; use the [generic HTTP provider](/guide/providers/http-polling) for other HTTP configuration services.
+
+```shell
+dotnet add package Cocoar.Configuration.ConfigHub
+```
+
+```csharp
+using Cocoar.Configuration.ConfigHub;
+
+builder.AddCocoarConfiguration(configuration => configuration
+ .UseConfiguration(rule =>
+ [
+ rule.For().FromFile("appsettings.json"),
+ rule.For().FromConfigHub(
+ "https://config.example/api/config/my-app",
+ Environment.GetEnvironmentVariable("CONFIGHUB_DELIVERY_TOKEN")!),
+ ]));
+```
+
+The local file supplies defaults. The later ConfigHub layer overrides the properties present in its snapshot and participates in the normal atomic merge and notification pipeline.
+
+## Delivery Protocol
+
+The provider treats the JSON snapshot as the only authoritative configuration:
+
+1. It fetches the endpoint with `Accept: application/json` and `Authorization: Bearer `.
+2. It stores the response ETag and opens the same endpoint with `Accept: text/event-stream`.
+3. An SSE event containing a `data` field invalidates the snapshot; the event payload itself is never parsed as configuration.
+4. The provider conditionally refetches with `If-None-Match`. A `304 Not Modified` produces no configuration update.
+5. Every SSE reconnect starts with a conditional refetch, closing the gap for events missed while disconnected.
+
+SSE reconnects use exponential backoff. A periodic conditional poll can run alongside it as an additional safety net.
+
+## Options
+
+| Option | Default | Description |
+|---|---|---|
+| `url` | Required | Absolute HTTP or HTTPS ConfigHub delivery URL |
+| `deliveryToken` | Required | Bearer token for the delivery endpoint |
+| `fallbackPollInterval` | `null` | Optional conditional poll running alongside SSE |
+| `sseReadIdleTimeout` | `null` | Reconnect if neither events nor keep-alive lines arrive in this interval |
+| `handler` | `null` | Optional caller-owned `HttpMessageHandler`, useful for custom transport or tests |
+
+Intervals must be greater than zero. The provider never disposes a supplied handler.
+
+## Required Configuration
+
+Mark the ConfigHub layer required when the application must not start without a successful initial snapshot:
+
+```csharp
+rule.For()
+ .FromConfigHub(deliveryUrl, deliveryToken)
+ .Required()
+```
+
+This makes endpoint, authentication, and initial download failures fail startup through Cocoar.Configuration's normal required-rule behavior. Without `.Required()`, lower layers can continue to provide defaults according to the optional-rule policy.
+
+## Dynamic Endpoints and Token Rotation
+
+Endpoint and token can depend on configuration established by earlier rules:
+
+```csharp
+rule =>
+[
+ rule.For().FromEnvironment("DEPLOYMENT_"),
+ rule.For().FromConfigHub(accessor =>
+ {
+ var deployment = accessor.GetConfig()!;
+ return new ConfigHubRuleOptions(
+ $"https://config.example/api/config/{deployment.Application}",
+ deployment.DeliveryToken,
+ fallbackPollInterval: TimeSpan.FromMinutes(5));
+ }),
+]
+```
+
+When the endpoint or token changes, Cocoar.Configuration rebuilds the query and its live subscription. The delivery token is excluded from serialized rule identity and provider diagnostics; a SHA-256 fingerprint provides change identity without serializing the credential itself.
+
+## Transport Customization
+
+Pass a caller-owned handler for mutual TLS, proxy settings, or a custom `DelegatingHandler` chain:
+
+```csharp
+var handler = new HttpClientHandler();
+handler.ClientCertificates.Add(clientCertificate);
+
+rule.For().FromConfigHub(
+ deliveryUrl,
+ deliveryToken,
+ handler: handler)
+```
+
+A rule with a custom handler receives a dedicated provider instance so unrelated rules cannot accidentally share transport state.
diff --git a/website/guide/providers/overview.md b/website/guide/providers/overview.md
index d38f75f..c765826 100644
--- a/website/guide/providers/overview.md
+++ b/website/guide/providers/overview.md
@@ -34,6 +34,7 @@ On failure, providers return an empty JSON object `{}` — never null. This mean
| [Static JSON](/guide/providers/static-observable#static-json) | `.FromStaticJson("{...}")` | No | Core |
| [Observable](/guide/providers/static-observable#observable) | `.FromObservable(obs)` | Yes | Core |
| [Writable Store](/guide/providers/writable-store) | `.FromStore()` | Yes (on write) | Core |
+| [ConfigHub](/guide/providers/confighub) | `.FromConfigHub(url, token)` | SSE invalidation / polling fallback | ConfigHub |
| [HTTP](/guide/providers/http-polling) | `.FromHttp(url)` | Polling / SSE / one-time | Http |
| [Microsoft IConfiguration](/guide/providers/microsoft-adapter) | `.FromIConfiguration(config)` | IConfiguration reload token | MicrosoftAdapter |
diff --git a/website/guide/roadmap.md b/website/guide/roadmap.md
index ddb6d4a..e8cba0a 100644
--- a/website/guide/roadmap.md
+++ b/website/guide/roadmap.md
@@ -4,4 +4,4 @@ description: Pointer to the full roadmap — ConfigHub, cloud providers, databas
# What's Next
-See the full [Roadmap](/roadmap/overview) for what's planned — including ConfigHub, cloud providers, database provider, and more.
+See the full [Roadmap](/roadmap/overview) for the available ConfigHub runtime provider and the planned ConfigHub portal, cloud providers, database provider, and more.
diff --git a/website/reference/packages.md b/website/reference/packages.md
index 4707721..1e000ca 100644
--- a/website/reference/packages.md
+++ b/website/reference/packages.md
@@ -1,5 +1,5 @@
---
-description: NuGet package breakdown — Abstractions, Core, DI, AspNetCore, Http, MicrosoftAdapter, WritableStore.Marten, Analyzers, Secrets CLI; dependency graph and which to install
+description: NuGet package breakdown — Abstractions, Core, DI, AspNetCore, ConfigHub, Http, MicrosoftAdapter, WritableStore.Marten, Analyzers, Secrets CLI; dependency graph and which to install
---
# Package Overview
@@ -70,6 +70,18 @@ Remote configuration provider with support for one-time fetch, polling, and Serv
```
+### Cocoar.Configuration.ConfigHub
+
+ConfigHub-specific delivery provider. It loads authenticated, authoritative JSON snapshots and uses Server-Sent Events only to invalidate the current snapshot. ETag validation, reconnect reconciliation, and optional polling fallback keep instances current without trusting event payloads as configuration.
+
+- **Target:** .NET 9.0 / .NET 10.0
+- **Dependencies:** Cocoar.Configuration
+- **Key types:** `FromConfigHub()` extension method, `ConfigHubRuleOptions`
+
+```xml
+
+```
+
### Cocoar.Configuration.MicrosoftAdapter
Bridge from `Microsoft.Extensions.Configuration` sources (Azure Key Vault, custom providers, etc.) into Cocoar.Configuration.
@@ -145,6 +157,7 @@ Abstractions (no deps)
▼
Core ◄──── Analyzers (build-time)
│
+ ├──► ConfigHub
├──► Http
├──► MicrosoftAdapter
│
@@ -165,12 +178,13 @@ Each arrow means "depends on". Installing a downstream package brings all upstre
| Console app or library with DI | `Cocoar.Configuration.DI` |
| Library without DI | `Cocoar.Configuration` |
| Interface-only dependency | `Cocoar.Configuration.Abstractions` |
+| ConfigHub-managed configuration | Add `Cocoar.Configuration.ConfigHub` |
| Remote config (polling / SSE) | Add `Cocoar.Configuration.Http` |
| Existing `IConfiguration` sources | Add `Cocoar.Configuration.MicrosoftAdapter` |
## External Dependencies
-All shipped packages have **zero non-Microsoft external dependencies**. The only third-party packages are Cocoar ecosystem libraries (`Cocoar.Capabilities`, `Cocoar.FileSystem`, `Cocoar.Json.Mutable`).
+Dependencies stay local to the package that needs them. `Cocoar.Configuration.ConfigHub` and `Cocoar.Configuration.Http` add no dependency beyond the core package and the .NET BCL. Format and persistence integrations intentionally bring their documented libraries, such as YamlDotNet, Tomlyn, and Marten; the core uses Cocoar ecosystem libraries and Microsoft abstractions.
`System.Reactive` is **not** a dependency — the library uses lightweight internal reactive primitives. Consumers are free to use System.Reactive on their side (the public API is `IObservable`, which is BCL).
diff --git a/website/roadmap/confighub.md b/website/roadmap/confighub.md
index 35ba6e4..6406c87 100644
--- a/website/roadmap/confighub.md
+++ b/website/roadmap/confighub.md
@@ -1,5 +1,5 @@
---
-description: ConfigHub management portal (commercial) — push config to fleets via FromConfigHub(), secret/cert lifecycle, feature flag control, health dashboard, OTLP telemetry
+description: ConfigHub management portal roadmap and the available Cocoar.Configuration.ConfigHub delivery provider
---
# ConfigHub
@@ -41,14 +41,16 @@ Rich per-rule health snapshots, recompute timing, provider error rates, configur
ConfigHub connects to your instances via the standard provider model. It's just another configuration source — the library doesn't know or care whether the bytes come from a file, HTTP endpoint, or ConfigHub:
```csharp
+using Cocoar.Configuration.ConfigHub;
+
builder.AddCocoarConfiguration(c => c
.UseConfiguration(rule => [
- rule.For().FromFile("appsettings.json"), // Local defaults
- rule.For().FromConfigHub(), // Remote overrides from ConfigHub
+ rule.For().FromFile("appsettings.json"),
+ rule.For().FromConfigHub(deliveryUrl, deliveryToken),
]));
```
-The `FromConfigHub()` provider uses the existing reactive pipeline — changes pushed from ConfigHub trigger the same recompute/merge/notify cycle as a file change. No special runtime behavior.
+The open-source [`Cocoar.Configuration.ConfigHub` provider](/guide/providers/confighub) is available as one dedicated opt-in package. It loads authoritative JSON snapshots, uses SSE as an invalidation signal, and feeds changes through the existing recompute/merge/notify pipeline.
### Data Flow
@@ -77,6 +79,6 @@ The library does **not** phone home, require a license key, or degrade without C
## Status
-ConfigHub is in the design phase. Architecture, data model, and provider protocol are being defined. A private preview is planned after the cloud providers ship.
+The runtime delivery provider and its protocol integration are implemented in the open-source library. The hosted ConfigHub management portal, data model, and operational features described above remain in design; a private preview is planned after the cloud providers ship.
If you're interested in early access, watch the [GitHub repository](https://github.com/cocoar-dev/Cocoar.Configuration) for announcements.
diff --git a/website/roadmap/overview.md b/website/roadmap/overview.md
index 3173f83..8799895 100644
--- a/website/roadmap/overview.md
+++ b/website/roadmap/overview.md
@@ -10,7 +10,7 @@ Cocoar.Configuration is the open-source foundation — fully functional today fo
| Initiative | Status | Impact |
|---|---|---|
-| [ConfigHub](/roadmap/confighub) | In Design | Management portal for config, secrets, and flags at scale |
+| [ConfigHub](/roadmap/confighub) | Provider available; portal in design | Management portal for config, secrets, and flags at scale |
| [Cloud Providers](/roadmap/cloud-providers) | Planned | Azure Key Vault, AWS Secrets Manager |
| [Database Provider](/roadmap/database-provider) | Planned | Tenant-specific config from SQL |