From fad56ee9c07981c4f6f6996b751dee6cd8182efd Mon Sep 17 00:00:00 2001 From: Bernhard Windisch Date: Sat, 30 May 2026 21:19:57 +0200 Subject: [PATCH 01/18] feat: LocalStorage sparse override overlay + secrets encryption-key publishing LocalStorage as a writable, last-layer sparse override overlay: - ILocalStorage (typed facade) + ILocalStorageOverlay (raw key-path) over one singleton adapter; sparse writes persist only the touched leaf, the effective value comes from the normal byte-level layer merge (unset keys keep inheriting) - Reset vs explicit-null are distinct; DescribeAsync() exposes per-key provenance - Secret-typed members are blocked from plaintext writes; SetSecretAsync / SetSecretEnvelopeAsync accept pre-encrypted cocoar.secret envelopes - File-based IStorageBackend by default; pluggable; IProviderServiceRegistration gains resolve-time factory registration Publish the public half of the secrets encryption key (one current key per kid): - ISecretEncryptionKeyProvider + ASP.NET Core endpoints under /.well-known/cocoar/encryption-keys; SecretEnvelope typed overlay writes Reuses BuildBaseJson / MutableJsonMerge as the overlay/merge foundation. --- CHANGELOG.md | 17 ++ README.md | 1 + .../LocalStorage/ILocalStorage.cs | 87 ++++++ .../LocalStorage/ILocalStorageOverlay.cs | 63 +++++ .../SecretEncryptionKey.cs | 91 +++++++ .../SecretEnvelope.cs | 58 ++++ .../SecretEncryptionKeyEndpointExtensions.cs | 89 ++++++ .../ServiceDescriptorEmitter.cs | 71 +++++ src/Cocoar.Configuration.slnx | 1 + .../Core/ConfigManager.cs | 42 +++ .../Properties/AssemblyInfo.cs | 1 + .../IProviderServiceRegistration.cs | 52 ++++ .../FileStorageBackend.cs | 76 ++++++ .../LocalStorageProvider/IStorageBackend.cs | 19 ++ .../LocalStorageAdapter.cs | 253 ++++++++++++++++++ .../LocalStorageProvider.cs | 29 ++ .../LocalStorageProviderOptions.cs | 44 +++ .../LocalStorageProviderQueryOptions.cs | 8 + .../LocalStorageRulesExtensions.cs | 87 ++++++ .../LocalStorageProvider/LocalStorageStore.cs | 108 ++++++++ .../OverlayPathResolver.cs | 156 +++++++++++ .../OverlaySerialization.cs | 44 +++ .../SparseOverlayMutator.cs | 152 +++++++++++ .../Rules/AggregateRuleManager.cs | 3 + .../Rules/IRuleManager.cs | 8 + src/Cocoar.Configuration/Rules/RuleManager.cs | 2 + .../Core/ISecretEncryptionKeyInfoProvider.cs | 14 + .../Core/SecretEncryptionKeyProvider.cs | 63 +++++ .../Protectors/Hybrid/CertificateInventory.cs | 44 +++ .../Hybrid/HybridProtectorRegistrar.cs | 17 +- .../Hybrid/InventoryKeyInfoProvider.cs | 37 +++ .../LocalStorageOverride.csproj | 16 ++ src/Examples/LocalStorageOverride/Program.cs | 92 +++++++ .../SecretEncryptionKeyEndpointTests.cs | 214 +++++++++++++++ .../LocalStorage/FileStorageBackendTests.cs | 95 +++++++ .../LocalStorageOverlayEndToEndTests.cs | 208 ++++++++++++++ .../LocalStorageSecretEnvelopeTests.cs | 118 ++++++++ .../LocalStorage/OverlayPathResolverTests.cs | 56 ++++ .../LocalStorage/OverlayTestSupport.cs | 54 ++++ .../LocalStorage/SparseOverlayMutatorTests.cs | 161 +++++++++++ .../SecretEncryptionKeyProviderTests.cs | 200 ++++++++++++++ website/.vitepress/config.ts | 1 + website/changelog.md | 13 + website/guide/providers/localstorage.md | 182 +++++++++++++ website/guide/providers/overview.md | 1 + 45 files changed, 3147 insertions(+), 1 deletion(-) create mode 100644 src/Cocoar.Configuration.Abstractions/LocalStorage/ILocalStorage.cs create mode 100644 src/Cocoar.Configuration.Abstractions/LocalStorage/ILocalStorageOverlay.cs create mode 100644 src/Cocoar.Configuration.Abstractions/SecretEncryptionKey.cs create mode 100644 src/Cocoar.Configuration.Abstractions/SecretEnvelope.cs create mode 100644 src/Cocoar.Configuration.AspNetCore/SecretEncryptionKeyEndpointExtensions.cs create mode 100644 src/Cocoar.Configuration/Providers/Abstractions/IProviderServiceRegistration.cs create mode 100644 src/Cocoar.Configuration/Providers/LocalStorageProvider/FileStorageBackend.cs create mode 100644 src/Cocoar.Configuration/Providers/LocalStorageProvider/IStorageBackend.cs create mode 100644 src/Cocoar.Configuration/Providers/LocalStorageProvider/LocalStorageAdapter.cs create mode 100644 src/Cocoar.Configuration/Providers/LocalStorageProvider/LocalStorageProvider.cs create mode 100644 src/Cocoar.Configuration/Providers/LocalStorageProvider/LocalStorageProviderOptions.cs create mode 100644 src/Cocoar.Configuration/Providers/LocalStorageProvider/LocalStorageProviderQueryOptions.cs create mode 100644 src/Cocoar.Configuration/Providers/LocalStorageProvider/LocalStorageRulesExtensions.cs create mode 100644 src/Cocoar.Configuration/Providers/LocalStorageProvider/LocalStorageStore.cs create mode 100644 src/Cocoar.Configuration/Providers/LocalStorageProvider/OverlayPathResolver.cs create mode 100644 src/Cocoar.Configuration/Providers/LocalStorageProvider/OverlaySerialization.cs create mode 100644 src/Cocoar.Configuration/Providers/LocalStorageProvider/SparseOverlayMutator.cs create mode 100644 src/Cocoar.Configuration/Secrets/Core/ISecretEncryptionKeyInfoProvider.cs create mode 100644 src/Cocoar.Configuration/Secrets/Core/SecretEncryptionKeyProvider.cs create mode 100644 src/Cocoar.Configuration/Secrets/Protectors/Hybrid/InventoryKeyInfoProvider.cs create mode 100644 src/Examples/LocalStorageOverride/LocalStorageOverride.csproj create mode 100644 src/Examples/LocalStorageOverride/Program.cs create mode 100644 src/tests/Cocoar.Configuration.AspNetCore.Tests/SecretEncryptionKeyEndpointTests.cs create mode 100644 src/tests/Cocoar.Configuration.Providers.Tests/LocalStorage/FileStorageBackendTests.cs create mode 100644 src/tests/Cocoar.Configuration.Providers.Tests/LocalStorage/LocalStorageOverlayEndToEndTests.cs create mode 100644 src/tests/Cocoar.Configuration.Providers.Tests/LocalStorage/LocalStorageSecretEnvelopeTests.cs create mode 100644 src/tests/Cocoar.Configuration.Providers.Tests/LocalStorage/OverlayPathResolverTests.cs create mode 100644 src/tests/Cocoar.Configuration.Providers.Tests/LocalStorage/OverlayTestSupport.cs create mode 100644 src/tests/Cocoar.Configuration.Providers.Tests/LocalStorage/SparseOverlayMutatorTests.cs create mode 100644 src/tests/Cocoar.Configuration.Providers.Tests/SecretEncryptionKeyProviderTests.cs create mode 100644 website/guide/providers/localstorage.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 678e684..da5339e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,10 +2,27 @@ ## [Unreleased] +### Added + +- **LocalStorage provider** — a writable, application-controlled override layer for *overridable defaults*: the normal sources (files, environment, …) supply defaults, and the application overrides individual values at runtime. + - `ILocalStorage` (type-safe facade) and `ILocalStorageOverlay` (raw key-path surface) in `Cocoar.Configuration.Abstractions` + - **Sparse writes** — `SetAsync(x => x.Smtp.Port, value)` persists only the touched leaf; unset keys keep inheriting from the lower layers + - `ResetAsync(...)` removes an override (value falls back to the inherited default); an explicit `null` override is distinct from reset + - `DescribeAsync()` returns per-key provenance (`OverrideEntry`: base value, effective value, `IsOverridden`) for management UIs + - `.FromLocalStorage()` rule extension; file-based backend by default with a pluggable `IStorageBackend` + - `ILocalStorage` / `ILocalStorageOverlay` are DI-injectable (single shared singleton) — write your own endpoints with your own validation/normalization/logging + - LocalStorageOverride example project +- `IProviderServiceRegistration` now supports resolve-time factory registrations (`ProviderServiceRegistration.Singleton(type, factory)`) in addition to eager instances + ### Changed - Secret payloads (the decrypted value of `Secret`) now (de)serialize with lenient options: **enums as names** (round-trip-safe if the enum is later reordered) and **case-insensitive** property matching. Reading still accepts numeric enums and any casing, so **existing encrypted secrets remain fully readable** — no migration. Only the in-memory form of newly serialized typed secret values changes (enum name instead of ordinal); encrypted envelopes at rest are unaffected. +### Notes + +- Secret-typed members (`Secret` / `ISecret`) cannot be overridden via LocalStorage — the typed facade throws `NotSupportedException` (manage secrets via the Secrets CLI/provider). +- Overlay values serialize with vanilla options (enums as strings) and overlay keys are aligned to the lower layers' casing, so an override **replaces** the base key rather than creating a casing-variant sibling. + ## [5.0.0] - 2026-03-24 ### Added diff --git a/README.md b/README.md index 3888deb..f055cdb 100644 --- a/README.md +++ b/README.md @@ -61,6 +61,7 @@ app.Run(); | Environment Variables | `.FromEnvironment("PREFIX_")` | Core | | Command Line | `.FromCommandLine("--prefix")` | Core | | Static / Observable | `.FromStaticJson()` / `.FromObservable()` | Core | +| LocalStorage (writable overlay) | `.FromLocalStorage()` | Core | | 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.Abstractions/LocalStorage/ILocalStorage.cs b/src/Cocoar.Configuration.Abstractions/LocalStorage/ILocalStorage.cs new file mode 100644 index 0000000..fa89a4f --- /dev/null +++ b/src/Cocoar.Configuration.Abstractions/LocalStorage/ILocalStorage.cs @@ -0,0 +1,87 @@ +using System.Linq.Expressions; +using System.Text.Json; +using Cocoar.Configuration.Secrets.SecretTypes; + +namespace Cocoar.Configuration.LocalStorage; + +/// +/// Type-safe facade for a LocalStorage override layer over configuration type . +/// +/// LocalStorage supplies overridable defaults: the normal sources (files, environment, …) provide +/// defaults, and the application overrides individual values at runtime. Writes are sparse — only +/// the keys you set are persisted, everything else continues to inherit from the lower layers. A write +/// triggers the normal recompute, so IReactiveConfig<T> emits the new effective value. +/// +/// +/// Secret-typed members (Secret<T> / ISecret<T>) cannot be overridden through this +/// API and throw ; manage secrets via the Secrets CLI/provider. +/// +/// +/// The configuration type this overlay targets. +public interface ILocalStorage where T : class +{ + /// + /// Overrides a single value selected by a member-access expression (e.g. x => x.Smtp.Port), + /// persisting only that leaf. The member chain is translated into a dotted key path and the value is + /// serialized with vanilla options (enums as strings); the lower layers are not otherwise touched. + /// + /// + /// The selector is not a simple member-access chain (e.g. it contains an indexer, method call, or cast), + /// or it targets a secret-typed member. + /// + Task SetAsync(Expression> selector, TValue value, CancellationToken ct = default); + + /// + /// Sets a pre-encrypted secret envelope for a secret-typed member (e.g. x => x.ApiKey), + /// encrypted client-side with the server's public certificate so plaintext never reaches the server. + /// The selector must point at a Secret<TSecret> / ISecret<TSecret> member, and the + /// must carry the same — so the envelope and + /// the target secret are matched at compile time. The normal still rejects + /// secret members (and objects containing secrets) to prevent storing plaintext. + /// + /// The envelope is not a well-formed cocoar.secret envelope. + Task SetSecretAsync(Expression>> selector, SecretEnvelope envelope, CancellationToken ct = default); + + /// + /// Resets a single value to its inherited (lower-layer) value by removing that leaf from the overlay. + /// + /// if an override was removed; if none existed. + Task ResetAsync(Expression> selector, CancellationToken ct = default); + + /// + /// Resets everything this layer overrides, so all keys inherit from the lower layers again. + /// + Task ClearAsync(CancellationToken ct = default); + + /// + /// Reads the sparse overlay into a partial where unset properties take their C# + /// defaults. Returns when nothing is overridden. For the merged/effective value, + /// use IReactiveConfig<T>.CurrentValue or IConfigurationAccessor.GetConfig<T>(). + /// + Task ReadAsync(CancellationToken ct = default); + + /// + /// Returns per-key provenance for a management UI: the union of leaf paths from the base (lower layers, + /// without this overlay) and the overlay, each annotated with its base value, effective value, and whether + /// it is currently overridden. + /// + Task> DescribeAsync(CancellationToken ct = default); + + /// + /// The raw, key-path overlay surface for dynamic or non-expressible paths. + /// + ILocalStorageOverlay Overlay { get; } +} + +/// +/// Per-leaf provenance entry produced by . +/// +/// Dotted leaf path (e.g. "Smtp.Port"). +/// The value from the lower layers, without this overlay; if absent there. +/// The merged/effective value seen by the application; if absent. +/// when the overlay supplies this key. +public sealed record OverrideEntry( + string KeyPath, + JsonElement? BaseValue, + JsonElement? EffectiveValue, + bool IsOverridden); diff --git a/src/Cocoar.Configuration.Abstractions/LocalStorage/ILocalStorageOverlay.cs b/src/Cocoar.Configuration.Abstractions/LocalStorage/ILocalStorageOverlay.cs new file mode 100644 index 0000000..681c390 --- /dev/null +++ b/src/Cocoar.Configuration.Abstractions/LocalStorage/ILocalStorageOverlay.cs @@ -0,0 +1,63 @@ +using System.Text.Json.Nodes; + +namespace Cocoar.Configuration.LocalStorage; + +/// +/// Raw, key-path patch surface for a LocalStorage override layer. +/// +/// LocalStorage contributes a sparse overlay: only the leaf keys it explicitly contains +/// override the lower configuration layers (files, environment, …). Keys that are absent from the +/// overlay inherit their value from those lower layers. This interface is the dependency-free +/// (JsonNode-based) escape hatch used by the typed facade and by +/// callers that need to address dynamic / non-expressible paths. +/// +/// +/// Segments of the key path must match the JSON property names used by the lower layers. +/// The typed facade resolves these automatically; when using this raw surface directly, the caller is +/// responsible for the names. Do not use this surface for secret-typed paths — see . +/// +/// +/// The configuration type this overlay targets. +public interface ILocalStorageOverlay where T : class +{ + /// + /// Sets a sparse, dotted key path (e.g. "Smtp.Port") to a JSON value, persisting only that leaf. + /// + /// Dotted path whose segments match the persisted JSON property names. + /// + /// The JSON value to store at the leaf. A reference writes an explicit JSON + /// null override (clobbers the lower-layer value to null) — this is distinct from + /// , which removes the override entirely. + /// + /// A token to cancel the write. + Task SetAsync(string keyPath, JsonNode? value, CancellationToken ct = default); + + /// + /// Sets a pre-encrypted secret envelope at a dotted key path. The value MUST be a well-formed + /// cocoar.secret envelope (produced client-side with the server's public certificate, or by the + /// Secrets CLI); plaintext and the masked "***" form are rejected, so the secret never reaches the + /// server in the clear. + /// + /// Dotted path to the secret member. + /// A well-formed encrypted secret envelope (object with type="cocoar.secret"). + /// A token to cancel the write. + /// The value is not a well-formed encrypted secret envelope. + Task SetSecretEnvelopeAsync(string keyPath, JsonNode envelope, CancellationToken ct = default); + + /// + /// Removes a key path from the overlay so the value falls back to the lower-layer (inherited) value. + /// + /// if a key was removed; if it was already absent. + Task ResetAsync(string keyPath, CancellationToken ct = default); + + /// + /// Clears the entire overlay, persisting an empty object so every key inherits again. + /// + Task ClearAsync(CancellationToken ct = default); + + /// + /// Reads the raw sparse overlay exactly as persisted (the override fragment, NOT the merged result). + /// Returns when the overlay is empty. + /// + Task ReadOverlayAsync(CancellationToken ct = default); +} diff --git a/src/Cocoar.Configuration.Abstractions/SecretEncryptionKey.cs b/src/Cocoar.Configuration.Abstractions/SecretEncryptionKey.cs new file mode 100644 index 0000000..6238871 --- /dev/null +++ b/src/Cocoar.Configuration.Abstractions/SecretEncryptionKey.cs @@ -0,0 +1,91 @@ +using System.Text.Json.Serialization; + +namespace Cocoar.Configuration.Secrets.SecretTypes; + +/// +/// Canonical algorithm identifiers for the cocoar.secret hybrid encryption scheme. +/// A single source of truth shared by and the published keys. +/// +public static class SecretAlgorithms +{ + /// Combined hybrid algorithm descriptor. "RSA-OAEP-AES256-GCM". + public const string Hybrid = "RSA-OAEP-AES256-GCM"; + + /// RSA key-wrapping algorithm (OAEP with SHA-256). "RSA-OAEP-256". + public const string KeyWrap = "RSA-OAEP-256"; + + /// Symmetric data-encryption algorithm. "AES-256-GCM". + public const string DataEncryption = "AES-256-GCM"; +} + +/// +/// The current public encryption key for one kid — the X.509 SubjectPublicKeyInfo a producer +/// (e.g. the @cocoar/secrets browser library) imports to build a matching cocoar.secret +/// envelope. Carries only public material; never a private key. +/// +public sealed record SecretEncryptionPublicKey +{ + /// Key identifier the producer stamps into the envelope kid field. + [JsonPropertyName("kid")] + public required string Kid { get; init; } + + /// Overall algorithm. "RSA-OAEP-AES256-GCM". + [JsonPropertyName("alg")] + public string Alg { get; init; } = SecretAlgorithms.Hybrid; + + /// Key-wrapping algorithm. "RSA-OAEP-256". + [JsonPropertyName("walg")] + public string Walg { get; init; } = SecretAlgorithms.KeyWrap; + + /// Data-encryption algorithm. "AES-256-GCM". + [JsonPropertyName("enc")] + public string Enc { get; init; } = SecretAlgorithms.DataEncryption; + + /// Public-key structure. Always "spki" (X.509 SubjectPublicKeyInfo, DER). + [JsonPropertyName("format")] + public string Format { get; init; } = "spki"; + + /// Encoding of . Always "base64url" (no padding). + [JsonPropertyName("encoding")] + public string Encoding { get; init; } = "base64url"; + + /// The RSA public key as DER SubjectPublicKeyInfo, base64url-encoded WITHOUT padding. + [JsonPropertyName("publicKey")] + public required string PublicKey { get; init; } +} + +/// +/// The published set of current encryption public keys (one per kid) — the wire shape of the list +/// endpoint: { "keys": [ ... ] }. The keys field name is pinned via +/// so a host JSON naming policy cannot rename it. +/// +public sealed record SecretEncryptionKeySet +{ + /// The current encryption public key for each configured kid. Never null; may be empty. + [JsonPropertyName("keys")] + public IReadOnlyList Keys { get; init; } = Array.Empty(); +} + +/// +/// Publishes the public half of the configured secrets encryption key(s) so external producers can +/// build cocoar.secret envelopes the server can later decrypt. Resolved from dependency injection. +/// +/// There is exactly ONE current key per kid — the certificate the decryption engine prefers. +/// Implementations re-read key material on every call so certificate rotation is reflected. Public keys +/// are safe to expose; no private key or plaintext is ever reachable through this API. +/// +/// +public interface ISecretEncryptionKeyProvider +{ + /// + /// Returns the current encryption public key for each configured kid (one per kid). Returns an + /// empty list when nothing is publishable; never throws for the "no keys" case. + /// + IReadOnlyList GetCurrentKeys(); + + /// + /// The current encryption public key for , or if that + /// kid is not currently published. + /// + SecretEncryptionPublicKey? GetCurrentKey(string kid); +} diff --git a/src/Cocoar.Configuration.Abstractions/SecretEnvelope.cs b/src/Cocoar.Configuration.Abstractions/SecretEnvelope.cs new file mode 100644 index 0000000..810d909 --- /dev/null +++ b/src/Cocoar.Configuration.Abstractions/SecretEnvelope.cs @@ -0,0 +1,58 @@ +using System.Text.Json.Serialization; + +namespace Cocoar.Configuration.Secrets.SecretTypes; + +/// +/// The wire / transport form of an encrypted secret — a cocoar.secret envelope produced +/// client-side (e.g. by the @cocoar/secrets browser library) or by the Secrets CLI. +/// +/// This is NOT a usable secret: it is ciphertext to be stored, and it cannot be opened without the +/// private key. It is deliberately a plain, JSON-bindable record (so an API request DTO can carry it) +/// — it does not inherit from Secret<T>, which has a different role (an openable +/// runtime secret). The phantom couples the envelope to the value type of the +/// target Secret<T> so the compiler can match them at the write call site. +/// +/// +/// Binary fields (, , , ) are +/// base64url WITHOUT padding — the encoding the decryption path requires. +/// +/// +/// The value type of the secret this envelope encrypts (phantom; for typing only). +public sealed record SecretEnvelope +{ + /// Envelope discriminator. Always "cocoar.secret". + [JsonPropertyName("type")] + public string Type { get; init; } = "cocoar.secret"; + + /// Envelope format version. Always 1. + [JsonPropertyName("version")] + public int Version { get; init; } = 1; + + /// Key identifier — must match a decryption key configured on the server. + [JsonPropertyName("kid")] + public required string Kid { get; init; } + + /// Overall algorithm. "RSA-OAEP-AES256-GCM". + [JsonPropertyName("alg")] + public string Alg { get; init; } = SecretAlgorithms.Hybrid; + + /// The AES-256 key wrapped with RSA-OAEP-SHA256 (base64url, no padding). + [JsonPropertyName("wk")] + public required string Wk { get; init; } + + /// Key-wrapping algorithm. "RSA-OAEP-256". + [JsonPropertyName("walg")] + public string Walg { get; init; } = SecretAlgorithms.KeyWrap; + + /// AES-GCM 96-bit initialization vector (base64url, no padding). + [JsonPropertyName("iv")] + public required string Iv { get; init; } + + /// AES-GCM ciphertext (base64url, no padding). + [JsonPropertyName("ct")] + public required string Ct { get; init; } + + /// AES-GCM 128-bit authentication tag (base64url, no padding). + [JsonPropertyName("tag")] + public required string Tag { get; init; } +} diff --git a/src/Cocoar.Configuration.AspNetCore/SecretEncryptionKeyEndpointExtensions.cs b/src/Cocoar.Configuration.AspNetCore/SecretEncryptionKeyEndpointExtensions.cs new file mode 100644 index 0000000..62ba956 --- /dev/null +++ b/src/Cocoar.Configuration.AspNetCore/SecretEncryptionKeyEndpointExtensions.cs @@ -0,0 +1,89 @@ +using Cocoar.Configuration.Secrets.SecretTypes; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.DependencyInjection; + +namespace Cocoar.Configuration.AspNetCore; + +/// +/// Extension methods for publishing the configured secrets encryption public key(s) so external +/// producers (e.g. a browser library) can build cocoar.secret envelopes the server decrypts. +/// Only public-key material is exposed — never a private key or plaintext. +/// +public static class SecretEncryptionKeyEndpointExtensions +{ + /// + /// Maps a GET endpoint that lists the current encryption public key per configured kid as + /// { "keys": [ ... ] }. Always returns 200 (an empty list when no key is publishable, e.g. + /// no secrets configured). Returns an so callers can chain + /// .RequireAuthorization(). Not secured by default (matches MapFeatureFlagEndpoints). + /// + public static IEndpointConventionBuilder MapSecretEncryptionKeys( + this IEndpointRouteBuilder endpoints, + string pattern = "/.well-known/cocoar/encryption-keys") + { + return endpoints.MapGet(pattern, (IServiceProvider sp) => + { + var provider = sp.GetService(); + var keys = provider?.GetCurrentKeys() ?? Array.Empty(); + return Results.Json(new SecretEncryptionKeySet { Keys = keys }); + }); + } + + /// + /// Maps a GET endpoint that returns the current encryption public key for a specific kid, or a + /// 404 ProblemDetails when that kid is not currently published. Returns an + /// for chaining .RequireAuthorization(). + /// + public static IEndpointConventionBuilder MapSecretEncryptionKeyByKid( + this IEndpointRouteBuilder endpoints, + string pattern = "/.well-known/cocoar/encryption-keys/{kid}") + { + return endpoints.MapGet(pattern, (string kid, IServiceProvider sp) => + { + var provider = sp.GetService(); + var key = provider?.GetCurrentKey(kid); + return key is null + ? Results.Problem( + detail: $"No published encryption key for kid '{kid}'.", + title: "Encryption key not found", + statusCode: StatusCodes.Status404NotFound) + : Results.Json(key); + }); + } + + /// + /// Maps both the list endpoint (at ) and the by-kid endpoint + /// (at {basePattern}/{kid}), returning a composite + /// so a single .RequireAuthorization() (or other convention) applies to both routes. + /// + public static IEndpointConventionBuilder MapSecretEncryptionKeyEndpoints( + this IEndpointRouteBuilder endpoints, + string basePattern = "/.well-known/cocoar/encryption-keys") + { + var list = endpoints.MapSecretEncryptionKeys(basePattern); + var byKid = endpoints.MapSecretEncryptionKeyByKid($"{basePattern.TrimEnd('/')}/{{kid}}"); + return new CompositeEndpointConventionBuilder(list, byKid); + } + + private sealed class CompositeEndpointConventionBuilder : IEndpointConventionBuilder + { + private readonly IEndpointConventionBuilder[] _builders; + + public CompositeEndpointConventionBuilder(params IEndpointConventionBuilder[] builders) + => _builders = builders; + + public void Add(Action convention) + { + foreach (var builder in _builders) + builder.Add(convention); + } + + public void Finally(Action finallyConvention) + { + foreach (var builder in _builders) + builder.Finally(finallyConvention); + } + } +} diff --git a/src/Cocoar.Configuration.DI/ServiceDescriptorEmitter.cs b/src/Cocoar.Configuration.DI/ServiceDescriptorEmitter.cs index 79cc371..d3559fc 100644 --- a/src/Cocoar.Configuration.DI/ServiceDescriptorEmitter.cs +++ b/src/Cocoar.Configuration.DI/ServiceDescriptorEmitter.cs @@ -1,7 +1,10 @@ using Cocoar.Configuration.Core; using Cocoar.Configuration.Flags; using Cocoar.Configuration.Flags.Internal; +using Cocoar.Configuration.Providers.Abstractions; using Cocoar.Configuration.Reactive; +using Cocoar.Configuration.Secrets.Core; +using Cocoar.Configuration.Secrets.SecretTypes; using Microsoft.Extensions.DependencyInjection; namespace Cocoar.Configuration.DI; @@ -31,6 +34,74 @@ public static void Emit( EmitFlagsServices(services, configManager); EmitEntitlementsServices(services, configManager); + EmitProviderContributedServices(services, configManager); + EmitSecretsKeyProviderServices(services, configManager); + } + + /// + /// Discovers provider options that implement and applies the + /// extra service registrations they contribute (e.g. LocalStorage's ILocalStorage<T> / + /// ILocalStorageOverlay<T>). Registrations may be eager singleton instances or resolve-time + /// factories. Collisions on the same service type are last-rule-wins; emission is ordered by service type + /// full name for determinism. + /// + private static void EmitProviderContributedServices(IServiceCollection services, ConfigManager configManager) + { + var registrations = new Dictionary(); + + foreach (var rule in configManager.Rules) + { + IProviderConfiguration options; + try + { + options = rule.ResolveProviderOptions(configManager); + } + catch + { + // A rule whose options can't be resolved at setup time contributes no services here; + // the recompute pipeline surfaces any real failure. + continue; + } + + if (options is not IProviderServiceRegistration provider) + { + continue; + } + + foreach (var registration in provider.GetServiceRegistrations(rule.ConcreteType)) + { + registrations[registration.ServiceType] = registration; // last-rule-wins + } + } + + foreach (var (serviceType, registration) in registrations.OrderBy(kvp => kvp.Key.FullName)) + { + if (registration.Instance is not null) + { + services.AddSingleton(serviceType, registration.Instance); + } + else + { + services.AddSingleton(serviceType, registration.Factory!); + } + } + } + + /// + /// Registers the public when a publishable encryption + /// key is configured (single-kid secrets compose an ). + /// The provider resolves the capability lazily per call so certificate rotation is reflected. + /// Not registered when no publishable key exists (no secrets, or decrypt-only folder mode). + /// + private static void EmitSecretsKeyProviderServices(IServiceCollection services, ConfigManager configManager) + { + var keyInfoProviders = configManager.CapabilityScope.Owner.GetComposition() + ?.GetAll(); + if (keyInfoProviders is null || keyInfoProviders.Count == 0) + return; + + services.AddSingleton( + sp => new SecretEncryptionKeyProvider(sp.GetRequiredService().CapabilityScope)); } private static void EmitFlagsServices(IServiceCollection services, ConfigManager configManager) diff --git a/src/Cocoar.Configuration.slnx b/src/Cocoar.Configuration.slnx index d66b9f8..389b20c 100644 --- a/src/Cocoar.Configuration.slnx +++ b/src/Cocoar.Configuration.slnx @@ -22,6 +22,7 @@ + diff --git a/src/Cocoar.Configuration/Core/ConfigManager.cs b/src/Cocoar.Configuration/Core/ConfigManager.cs index 8b56fa0..dce6f53 100644 --- a/src/Cocoar.Configuration/Core/ConfigManager.cs +++ b/src/Cocoar.Configuration/Core/ConfigManager.cs @@ -12,6 +12,7 @@ using Cocoar.Configuration.Rules; using Cocoar.Configuration.Reactive; using Cocoar.Configuration.Utilities; +using Cocoar.Json.Mutable; namespace Cocoar.Configuration.Core; @@ -253,6 +254,47 @@ public bool TryGetConfig(out T? value) where T : class /// The configuration type to retrieve. public JsonElement? GetConfigAsJson(Type type) => _accessor.GetConfigAsJson(type); + /// + /// Computes the merged "base" JSON for from all rule layers BELOW the + /// overlay layer identified by — i.e. the value the type would have + /// without that overlay. Used by LocalStorage to align override key casing against lower layers and to + /// report base-vs-effective provenance. + /// + /// Thread-safety: this reads each manager's LastJsonContribution without taking the recompute + /// semaphore — deliberately, because reactive notifications are published inside that semaphore, + /// so gating here would deadlock a subscriber that writes back. It is safe because a contribution is + /// written once per recompute and then replaced wholesale (never mutated in place), and the reference + /// read is atomic: a concurrent recompute can at worst make this observe a one-generation-stale but + /// internally-consistent contribution, which self-heals on the next read. + /// + /// + internal MutableJsonObject BuildBaseJson(Type configType, Func isExcludedLayer) + { + ArgumentNullException.ThrowIfNull(configType); + ArgumentNullException.ThrowIfNull(isExcludedLayer); + + var merged = new MutableJsonObject(); + foreach (var manager in _ruleManagers) + { + if (isExcludedLayer(manager)) + { + break; // the base is everything strictly below the overlay layer + } + + if (manager.TypeDefinition != configType) + { + continue; + } + + if (manager.LastJsonContribution is { } contribution) + { + MutableJsonMerge.Merge(merged, contribution); + } + } + + return merged; + } + /// /// Gets a reactive wrapper for the specified configuration type. /// The returned emits the current value immediately on subscribe diff --git a/src/Cocoar.Configuration/Properties/AssemblyInfo.cs b/src/Cocoar.Configuration/Properties/AssemblyInfo.cs index 25e7aac..0de3212 100644 --- a/src/Cocoar.Configuration/Properties/AssemblyInfo.cs +++ b/src/Cocoar.Configuration/Properties/AssemblyInfo.cs @@ -9,6 +9,7 @@ [assembly: InternalsVisibleTo("Cocoar.Configuration.Secrets.Tests")] [assembly: InternalsVisibleTo("Cocoar.Configuration.AspNetCore")] [assembly: InternalsVisibleTo("Cocoar.Configuration.Http")] +[assembly: InternalsVisibleTo("Cocoar.Configuration.Providers.Tests")] // Type forwarding for types moved to Cocoar.Configuration.Abstractions [assembly: TypeForwardedTo(typeof(IConfigurationAccessor))] diff --git a/src/Cocoar.Configuration/Providers/Abstractions/IProviderServiceRegistration.cs b/src/Cocoar.Configuration/Providers/Abstractions/IProviderServiceRegistration.cs new file mode 100644 index 0000000..6d573bf --- /dev/null +++ b/src/Cocoar.Configuration/Providers/Abstractions/IProviderServiceRegistration.cs @@ -0,0 +1,52 @@ +namespace Cocoar.Configuration.Providers.Abstractions; + +/// +/// Implemented by provider options that need additional services registered in DI +/// beyond the standard config type and IReactiveConfig<T>. +/// +/// The DI emitter discovers this interface by scanning resolved provider options +/// for all rules. No hardcoded provider knowledge is needed in the emitter. +/// +/// +public interface IProviderServiceRegistration +{ + /// + /// Returns additional service registrations (eager instances and/or resolve-time factories) to apply in DI. + /// Called once during DI setup — not on every recompute. + /// + /// The configuration type this rule targets (e.g., typeof(AppSettings)). + IEnumerable GetServiceRegistrations(Type concreteType); +} + +/// +/// A single service registration contributed by a provider. Expresses either a pre-built singleton +/// or a resolve-time . Uses only BCL types +/// () so the shipped package stays free of a DI dependency; the DI emitter +/// translates it into a concrete ServiceDescriptor. +/// +public sealed class ProviderServiceRegistration +{ + private ProviderServiceRegistration(Type serviceType, object? instance, Func? factory) + { + ServiceType = serviceType ?? throw new ArgumentNullException(nameof(serviceType)); + Instance = instance; + Factory = factory; + } + + /// The service type to register (e.g. ILocalStorage<AppSettings>). + public Type ServiceType { get; } + + /// The pre-built singleton instance, or when a is used. + public object? Instance { get; } + + /// The resolve-time factory, or when an is used. + public Func? Factory { get; } + + /// Registers a pre-built singleton instance. + public static ProviderServiceRegistration Singleton(Type serviceType, object instance) + => new(serviceType, instance ?? throw new ArgumentNullException(nameof(instance)), null); + + /// Registers a singleton built by a resolve-time factory (so it can pull other DI services). + public static ProviderServiceRegistration Singleton(Type serviceType, Func factory) + => new(serviceType, null, factory ?? throw new ArgumentNullException(nameof(factory))); +} diff --git a/src/Cocoar.Configuration/Providers/LocalStorageProvider/FileStorageBackend.cs b/src/Cocoar.Configuration/Providers/LocalStorageProvider/FileStorageBackend.cs new file mode 100644 index 0000000..9affa3f --- /dev/null +++ b/src/Cocoar.Configuration/Providers/LocalStorageProvider/FileStorageBackend.cs @@ -0,0 +1,76 @@ +namespace Cocoar.Configuration.Providers; + +/// +/// File-based storage backend using atomic write-temp-then-rename pattern. +/// Default directory: {AppContext.BaseDirectory}/.cocoar/localStorage/ +/// +public sealed class FileStorageBackend : IStorageBackend +{ + private readonly string _directory; + + public FileStorageBackend(string? directory = null) + { + _directory = directory + ?? Path.Combine(AppContext.BaseDirectory, ".cocoar", "localStorage"); + } + + public async Task ReadAsync(string key, CancellationToken ct = default) + { + var path = GetFilePath(key); + + try + { + return await File.ReadAllBytesAsync(path, ct).ConfigureAwait(false); + } + catch (FileNotFoundException) + { + // Nothing persisted yet, or the file was removed between checks (TOCTOU-safe). + return null; + } + catch (DirectoryNotFoundException) + { + return null; + } + } + + public async Task WriteAsync(string key, byte[] data, CancellationToken ct = default) + { + Directory.CreateDirectory(_directory); + + var path = GetFilePath(key); + // Per-write unique temp name so concurrent writers never clobber each other's intermediate file. + var tempPath = path + "." + Guid.NewGuid().ToString("N") + ".tmp"; + + try + { + await File.WriteAllBytesAsync(tempPath, data, ct).ConfigureAwait(false); + File.Move(tempPath, path, overwrite: true); + } + catch + { + TryDeleteTemp(tempPath); + throw; + } + } + + private static void TryDeleteTemp(string tempPath) + { + try + { + if (File.Exists(tempPath)) + { + File.Delete(tempPath); + } + } + catch + { + // Best-effort cleanup of a stale temp file; never mask the original write failure. + } + } + + private string GetFilePath(string key) + { + var safeKey = string.Join("_", key.Split(Path.GetInvalidFileNameChars())); + return Path.Combine(_directory, safeKey + ".json"); + } +} diff --git a/src/Cocoar.Configuration/Providers/LocalStorageProvider/IStorageBackend.cs b/src/Cocoar.Configuration/Providers/LocalStorageProvider/IStorageBackend.cs new file mode 100644 index 0000000..278f5e8 --- /dev/null +++ b/src/Cocoar.Configuration/Providers/LocalStorageProvider/IStorageBackend.cs @@ -0,0 +1,19 @@ +namespace Cocoar.Configuration.Providers; + +/// +/// Abstraction for the persistence layer used by LocalStorageProvider. +/// Default implementation is file-based; can be replaced with SQLite, Marten, etc. +/// +public interface IStorageBackend +{ + /// + /// Reads raw UTF-8 JSON bytes for the given key. + /// Returns null if no data has been persisted yet. + /// + Task ReadAsync(string key, CancellationToken ct = default); + + /// + /// Writes raw UTF-8 JSON bytes atomically for the given key. + /// + Task WriteAsync(string key, byte[] data, CancellationToken ct = default); +} diff --git a/src/Cocoar.Configuration/Providers/LocalStorageProvider/LocalStorageAdapter.cs b/src/Cocoar.Configuration/Providers/LocalStorageProvider/LocalStorageAdapter.cs new file mode 100644 index 0000000..f794dab --- /dev/null +++ b/src/Cocoar.Configuration/Providers/LocalStorageProvider/LocalStorageAdapter.cs @@ -0,0 +1,253 @@ +using System.Linq.Expressions; +using System.Text.Json; +using System.Text.Json.Nodes; +using Cocoar.Configuration.Core; +using Cocoar.Configuration.LocalStorage; +using Cocoar.Configuration.Rules; +using Cocoar.Configuration.Secrets.Core; +using Cocoar.Configuration.Secrets.SecretTypes; +using Cocoar.Json.Mutable; + +namespace Cocoar.Configuration.Providers; + +/// +/// Implements both the type-safe facade and the raw +/// surface over a single . +/// All writes are sparse (only the touched leaf is persisted) and go through the store's atomic +/// read-transform-write lock; provenance is computed from the base layers, the merged effective value, +/// and the persisted overlay. +/// +internal sealed class LocalStorageAdapter : ILocalStorage, ILocalStorageOverlay, IDisposable + where T : class +{ + private readonly ConfigManager _configManager; + private readonly LocalStorageStore _store; + + public LocalStorageAdapter(ConfigManager configManager, LocalStorageStore store) + { + _configManager = configManager ?? throw new ArgumentNullException(nameof(configManager)); + _store = store ?? throw new ArgumentNullException(nameof(store)); + } + + public ILocalStorageOverlay Overlay => this; + + // ---------------------------------------------------------------- typed facade + + public Task SetAsync(Expression> selector, TValue value, CancellationToken ct = default) + { + if (OverlayPathResolver.ContainsSecret(typeof(TValue))) + { + throw new NotSupportedException( + $"Cannot store a value of type '{typeof(TValue).Name}' via SetAsync because it is, or contains, a secret. " + + "A secret would be serialized as plaintext (or lost). Set secret members individually via " + + "SetSecretAsync with a pre-encrypted SecretEnvelope."); + } + + var keyPath = OverlayPathResolver.ResolveKeyPath(selector); + var node = OverlaySerialization.SerializeValue(value); + return SetAsync(keyPath, node, ct); + } + + public Task ResetAsync(Expression> selector, CancellationToken ct = default) + { + var keyPath = OverlayPathResolver.ResolveKeyPath(selector); + return ResetAsync(keyPath, ct); + } + + public Task SetSecretAsync(Expression>> selector, SecretEnvelope envelope, CancellationToken ct = default) + { + ArgumentNullException.ThrowIfNull(envelope); + // Secret members are allowed here ONLY because the value is a pre-encrypted envelope (validated below). + var keyPath = OverlayPathResolver.ResolveKeyPath(selector, allowSecretMembers: true); + var node = JsonSerializer.SerializeToNode(envelope)!; + return SetSecretEnvelopeAsync(keyPath, node, ct); + } + + public async Task ReadAsync(CancellationToken ct = default) + { + var bytes = await _store.ReadBytesAsync(ct).ConfigureAwait(false); + if (bytes.Length <= 2) + { + return null; + } + + return JsonSerializer.Deserialize(bytes, OverlaySerialization.ReadOptions); + } + + public async Task> DescribeAsync(CancellationToken ct = default) + { + var baseElement = ToJsonElement(_configManager.BuildBaseJson(typeof(T), IsThisLayer)); + var effective = _configManager.GetConfigAsJson(typeof(T)); + var overlayNode = await ReadOverlayAsync(ct).ConfigureAwait(false); + + var overriddenPaths = new HashSet(StringComparer.Ordinal); + if (overlayNode is JsonObject overlayObject) + { + CollectOverlayPaths(overlayObject, null, overriddenPaths); + } + + var allPaths = new SortedSet(StringComparer.Ordinal); + CollectLeafPaths(baseElement, null, allPaths); + if (effective is { } effectiveElement) + { + CollectLeafPaths(effectiveElement, null, allPaths); + } + allPaths.UnionWith(overriddenPaths); + + var entries = new List(allPaths.Count); + foreach (var path in allPaths) + { + JsonElement? baseValue = TrySelect(baseElement, path, out var bv) ? bv : null; + JsonElement? effectiveValue = + effective is { } e && TrySelect(e, path, out var ev) ? ev : null; + + entries.Add(new OverrideEntry(path, baseValue, effectiveValue, overriddenPaths.Contains(path))); + } + + return entries; + } + + // ---------------------------------------------------------------- raw overlay surface + + public async Task SetAsync(string keyPath, JsonNode? value, CancellationToken ct = default) + { + ValidateKeyPath(keyPath); + var baseDom = _configManager.BuildBaseJson(typeof(T), IsThisLayer); + await _store.UpdateBytesAsync(bytes => SparseOverlayMutator.Set(bytes, keyPath, value, baseDom), ct) + .ConfigureAwait(false); + } + + public async Task SetSecretEnvelopeAsync(string keyPath, JsonNode envelope, CancellationToken ct = default) + { + ArgumentNullException.ThrowIfNull(envelope); + ValidateKeyPath(keyPath); + + // Only pre-encrypted envelopes are accepted — never plaintext (which would expose the secret) nor the + // masked "***" form (which would destroy a real secret on the lower layer). + var element = JsonSerializer.SerializeToElement(envelope); + if (!SecretEnvelopeWrapper.IsEnvelope(element)) + { + throw new ArgumentException( + "Value is not a well-formed encrypted secret envelope (expected an object with " + + "type=\"cocoar.secret\" and version=1). LocalStorage only accepts pre-encrypted secret envelopes.", + nameof(envelope)); + } + + var baseDom = _configManager.BuildBaseJson(typeof(T), IsThisLayer); + await _store.UpdateBytesAsync(bytes => SparseOverlayMutator.Set(bytes, keyPath, envelope, baseDom), ct) + .ConfigureAwait(false); + } + + public async Task ResetAsync(string keyPath, CancellationToken ct = default) + { + ValidateKeyPath(keyPath); + var removed = false; + await _store.UpdateBytesAsync(bytes => + { + var (updated, didRemove) = SparseOverlayMutator.Remove(bytes, keyPath); + removed = didRemove; + return updated; + }, ct).ConfigureAwait(false); + return removed; + } + + public Task ClearAsync(CancellationToken ct = default) + => _store.WriteBytesAsync("{}"u8.ToArray(), ct); + + public async Task ReadOverlayAsync(CancellationToken ct = default) + { + var bytes = await _store.ReadBytesAsync(ct).ConfigureAwait(false); + if (bytes.Length <= 2) + { + return null; + } + + return JsonNode.Parse(bytes); + } + + public void Dispose() => _store.Dispose(); + + // ---------------------------------------------------------------- helpers + + private bool IsThisLayer(IRuleManager manager) + => manager.CurrentProvider is LocalStorageProvider provider && ReferenceEquals(provider.Store, _store); + + private static void ValidateKeyPath(string keyPath) + { + if (string.IsNullOrWhiteSpace(keyPath)) + { + throw new ArgumentException("Key path must be a non-empty, dotted property path.", nameof(keyPath)); + } + + foreach (var segment in keyPath.Split('.')) + { + if (string.IsNullOrWhiteSpace(segment)) + { + throw new ArgumentException( + $"Key path '{keyPath}' contains an empty segment.", nameof(keyPath)); + } + } + } + + private static JsonElement ToJsonElement(MutableJsonObject obj) + { + var bytes = MutableJsonDocument.ToUtf8Bytes(obj); + using var document = JsonDocument.Parse(bytes); + return document.RootElement.Clone(); + } + + private static void CollectLeafPaths(JsonElement element, string? prefix, ISet paths) + { + if (element.ValueKind == JsonValueKind.Object) + { + foreach (var property in element.EnumerateObject()) + { + var childPath = prefix is null ? property.Name : $"{prefix}.{property.Name}"; + CollectLeafPaths(property.Value, childPath, paths); + } + + return; + } + + // Arrays and scalars (and explicit null) are treated as leaves — the merge replaces arrays wholesale. + if (prefix is not null) + { + paths.Add(prefix); + } + } + + private static void CollectOverlayPaths(JsonObject node, string? prefix, ISet paths) + { + foreach (var (key, value) in node) + { + var childPath = prefix is null ? key : $"{prefix}.{key}"; + if (value is JsonObject childObject) + { + CollectOverlayPaths(childObject, childPath, paths); + } + else + { + // A JsonValue, JsonArray, or explicit null (value is null) is an overridden leaf. + paths.Add(childPath); + } + } + } + + private static bool TrySelect(JsonElement root, string dottedPath, out JsonElement result) + { + var current = root; + foreach (var segment in dottedPath.Split('.')) + { + if (current.ValueKind != JsonValueKind.Object || !current.TryGetProperty(segment, out var next)) + { + result = default; + return false; + } + + current = next; + } + + result = current; + return true; + } +} diff --git a/src/Cocoar.Configuration/Providers/LocalStorageProvider/LocalStorageProvider.cs b/src/Cocoar.Configuration/Providers/LocalStorageProvider/LocalStorageProvider.cs new file mode 100644 index 0000000..ad2d75e --- /dev/null +++ b/src/Cocoar.Configuration/Providers/LocalStorageProvider/LocalStorageProvider.cs @@ -0,0 +1,29 @@ +using Cocoar.Configuration.Providers.Abstractions; + +namespace Cocoar.Configuration.Providers; + +/// +/// Provider that reads from a . +/// The store is shared state owned by the closure in FromLocalStorage() — +/// the provider does NOT own or dispose it. +/// +public sealed class LocalStorageProvider(LocalStorageProviderOptions options) + : ConfigurationProvider(options) +{ + /// + /// The store backing this provider. Exposed so the overlay layer can be located in the rule + /// pipeline by store reference (used for base/prefix computation and provenance). + /// + internal LocalStorageStore Store => ProviderOptions.Store; + + public override async Task FetchConfigurationBytesAsync( + LocalStorageProviderQueryOptions query, CancellationToken ct = default) + { + return await ProviderOptions.Store.ReadBytesAsync(ct).ConfigureAwait(false); + } + + public override IObservable ChangesAsBytes(LocalStorageProviderQueryOptions query) + { + return ProviderOptions.Store.Changes; + } +} diff --git a/src/Cocoar.Configuration/Providers/LocalStorageProvider/LocalStorageProviderOptions.cs b/src/Cocoar.Configuration/Providers/LocalStorageProvider/LocalStorageProviderOptions.cs new file mode 100644 index 0000000..22be37c --- /dev/null +++ b/src/Cocoar.Configuration/Providers/LocalStorageProvider/LocalStorageProviderOptions.cs @@ -0,0 +1,44 @@ +using Cocoar.Configuration.Core; +using Cocoar.Configuration.LocalStorage; +using Cocoar.Configuration.Providers.Abstractions; + +namespace Cocoar.Configuration.Providers; + +public sealed class LocalStorageProviderOptions(LocalStorageStore store) + : IProviderConfiguration, IProviderServiceRegistration +{ + public LocalStorageStore Store { get; } = store ?? throw new ArgumentNullException(nameof(store)); + + /// + /// Returns null to indicate non-reusable. Each LocalStorage rule gets its own provider instance + /// because each is backed by a unique tied to a specific configuration type. + /// + public string? GenerateProviderKey() => null; + + /// + /// Registers the overlay adapter as a singleton built at resolve time (so it can pull + /// for base/prefix computation), then resolves both + /// and to that one instance. + /// + public IEnumerable GetServiceRegistrations(Type concreteType) + { + var adapterType = typeof(LocalStorageAdapter<>).MakeGenericType(concreteType); + var storageInterface = typeof(ILocalStorage<>).MakeGenericType(concreteType); + var overlayInterface = typeof(ILocalStorageOverlay<>).MakeGenericType(concreteType); + var store = Store; + + yield return ProviderServiceRegistration.Singleton(adapterType, sp => + { + var configManager = (ConfigManager?)sp.GetService(typeof(ConfigManager)) + ?? throw new InvalidOperationException( + "ConfigManager is not registered. Call AddCocoarConfiguration before resolving ILocalStorage."); + return Activator.CreateInstance(adapterType, configManager, store)!; + }); + + yield return ProviderServiceRegistration.Singleton(storageInterface, + sp => sp.GetService(adapterType)!); + + yield return ProviderServiceRegistration.Singleton(overlayInterface, + sp => sp.GetService(adapterType)!); + } +} diff --git a/src/Cocoar.Configuration/Providers/LocalStorageProvider/LocalStorageProviderQueryOptions.cs b/src/Cocoar.Configuration/Providers/LocalStorageProvider/LocalStorageProviderQueryOptions.cs new file mode 100644 index 0000000..1fc5160 --- /dev/null +++ b/src/Cocoar.Configuration/Providers/LocalStorageProvider/LocalStorageProviderQueryOptions.cs @@ -0,0 +1,8 @@ +using Cocoar.Configuration.Providers.Abstractions; + +namespace Cocoar.Configuration.Providers; + +public sealed class LocalStorageProviderQueryOptions : IProviderQuery +{ + public static readonly LocalStorageProviderQueryOptions Default = new(); +} diff --git a/src/Cocoar.Configuration/Providers/LocalStorageProvider/LocalStorageRulesExtensions.cs b/src/Cocoar.Configuration/Providers/LocalStorageProvider/LocalStorageRulesExtensions.cs new file mode 100644 index 0000000..22851a3 --- /dev/null +++ b/src/Cocoar.Configuration/Providers/LocalStorageProvider/LocalStorageRulesExtensions.cs @@ -0,0 +1,87 @@ +using Cocoar.Configuration.Core; +using Cocoar.Configuration.Fluent; + +namespace Cocoar.Configuration.Providers; + +public static class LocalStorageRulesExtensions +{ + /// + /// Creates a local-storage-backed configuration rule. + /// Reads from and writes to persistent storage. By default uses file-based storage + /// at {AppContext.BaseDirectory}/.cocoar/localStorage/. + /// + /// + /// + /// Use (via DI) to write configuration at runtime. + /// Writes trigger a recompute of the configuration pipeline. + /// + /// + /// Position this rule in the pipeline to control priority: later rules override earlier ones. + /// + /// + /// The typed provider builder. + /// Optional custom storage backend. Defaults to . + public static ProviderRuleBuilder + FromLocalStorage(this TypedProviderBuilder builder, IStorageBackend? backend = null) + where T : class + { + var effectiveBackend = backend ?? new FileStorageBackend(); + var storageKey = typeof(T).FullName ?? typeof(T).Name; + var store = new LocalStorageStore(effectiveBackend, storageKey) + { + ConfigurationType = typeof(T) + }; + + return new( + _ => new LocalStorageProviderOptions(store), + _ => LocalStorageProviderQueryOptions.Default, + typeof(T) + ); + } + + /// + /// Creates a local-storage-backed configuration rule using a factory that receives the current + /// configuration state and the current backend. Use this when the storage backend depends on + /// values from earlier rules (e.g., a connection string for a database-backed backend). + /// + /// + /// The factory is called on every recompute. The second parameter (currentBackend) is + /// the backend currently in use (null on the first call). Return it unchanged to skip + /// creating a new instance when nothing relevant changed — this avoids unnecessary + /// connection pool churn for database backends. + /// + /// The typed provider builder. + /// A factory that receives the current + /// and the current (null on first call), and returns the backend to use. + public static ProviderRuleBuilder + FromLocalStorage(this TypedProviderBuilder builder, Func backendFactory) + where T : class + { + ArgumentNullException.ThrowIfNull(backendFactory); + + LocalStorageStore? store = null; + var storageKey = typeof(T).FullName ?? typeof(T).Name; + + return new( + accessor => + { + var currentBackend = store?.Backend; + var backend = backendFactory(accessor, currentBackend); + if (store is null) + { + store = new LocalStorageStore(backend, storageKey) + { + ConfigurationType = typeof(T) + }; + } + else if (!ReferenceEquals(backend, currentBackend)) + { + store.ReplaceBackend(backend); + } + return new LocalStorageProviderOptions(store); + }, + _ => LocalStorageProviderQueryOptions.Default, + typeof(T) + ); + } +} diff --git a/src/Cocoar.Configuration/Providers/LocalStorageProvider/LocalStorageStore.cs b/src/Cocoar.Configuration/Providers/LocalStorageProvider/LocalStorageStore.cs new file mode 100644 index 0000000..c5c4ea4 --- /dev/null +++ b/src/Cocoar.Configuration/Providers/LocalStorageProvider/LocalStorageStore.cs @@ -0,0 +1,108 @@ +using Cocoar.Configuration.Reactive.Internal; + +namespace Cocoar.Configuration.Providers; + +/// +/// Shared state object that bridges the provider (read path) and ILocalStorage<T> (write path). +/// Created once in FromLocalStorage() and captured by both the provider options closure and DI registration. +/// +public sealed class LocalStorageStore : IDisposable +{ + private volatile IStorageBackend _backend; + private readonly string _storageKey; + private readonly SimpleSubject _changeSubject = new(); + private readonly SemaphoreSlim _writeLock = new(1, 1); + + public LocalStorageStore(IStorageBackend backend, string storageKey) + { + ArgumentNullException.ThrowIfNull(backend); + ArgumentException.ThrowIfNullOrWhiteSpace(storageKey); + + _backend = backend; + _storageKey = storageKey; + } + + /// + /// The current storage backend. Exposed so the config-aware factory can + /// pass it back to the user for comparison/reuse decisions. + /// + internal IStorageBackend Backend => _backend; + + /// + /// Replaces the storage backend. Called during recompute when config-aware + /// factory produces a new backend (e.g., connection string changed). + /// The store instance stays the same — DI references remain valid. + /// + internal void ReplaceBackend(IStorageBackend backend) + { + ArgumentNullException.ThrowIfNull(backend); + _backend = backend; + } + + /// + /// The configuration type this store is associated with. + /// Used by DI registration to match ILocalStorage<T> to the correct store. + /// + public Type ConfigurationType { get; init; } = null!; + + /// + /// Observable that fires when new bytes are written. Subscribed to by the provider. + /// + internal IObservable Changes => _changeSubject; + + /// + /// Reads current bytes from storage. Returns empty JSON object if nothing persisted yet. + /// + internal async Task ReadBytesAsync(CancellationToken ct = default) + { + var data = await _backend.ReadAsync(_storageKey, ct).ConfigureAwait(false); + return data ?? "{}"u8.ToArray(); + } + + /// + /// Writes bytes to storage and signals the change subject. + /// Thread-safe via SemaphoreSlim. + /// + public async Task WriteBytesAsync(byte[] data, CancellationToken ct = default) + { + ArgumentNullException.ThrowIfNull(data); + + await _writeLock.WaitAsync(ct).ConfigureAwait(false); + try + { + await _backend.WriteAsync(_storageKey, data, ct).ConfigureAwait(false); + _changeSubject.OnNext(data); + } + finally + { + _writeLock.Release(); + } + } + + /// + /// Atomically reads current bytes, applies a transform, and writes the result. + /// The entire read-transform-write cycle runs under the write lock. + /// + internal async Task UpdateBytesAsync(Func transform, CancellationToken ct = default) + { + await _writeLock.WaitAsync(ct).ConfigureAwait(false); + try + { + var current = await _backend.ReadAsync(_storageKey, ct).ConfigureAwait(false) + ?? "{}"u8.ToArray(); + var updated = transform(current); + await _backend.WriteAsync(_storageKey, updated, ct).ConfigureAwait(false); + _changeSubject.OnNext(updated); + } + finally + { + _writeLock.Release(); + } + } + + public void Dispose() + { + _writeLock.Dispose(); + _changeSubject.Dispose(); + } +} diff --git a/src/Cocoar.Configuration/Providers/LocalStorageProvider/OverlayPathResolver.cs b/src/Cocoar.Configuration/Providers/LocalStorageProvider/OverlayPathResolver.cs new file mode 100644 index 0000000..f00dfee --- /dev/null +++ b/src/Cocoar.Configuration/Providers/LocalStorageProvider/OverlayPathResolver.cs @@ -0,0 +1,156 @@ +using System.Linq.Expressions; +using System.Reflection; +using System.Text.Json.Serialization; +using Cocoar.Configuration.Secrets.SecretTypes; + +namespace Cocoar.Configuration.Providers; + +/// +/// Translates a member-access selector (e.g. x => x.Smtp.Port) into a dotted JSON key path, +/// resolving each segment's JSON property name (honoring ) and +/// rejecting unsupported selectors (indexers, method calls, casts) and secret-typed members. +/// +internal static class OverlayPathResolver +{ + internal static string ResolveKeyPath(Expression> selector, bool allowSecretMembers = false) + { + ArgumentNullException.ThrowIfNull(selector); + + var body = Unwrap(selector.Body); + + var members = new List(); + var current = body; + while (current is MemberExpression memberExpression) + { + if (memberExpression.Member is not PropertyInfo and not FieldInfo) + { + throw Unsupported(selector); + } + + members.Add(memberExpression.Member); + current = Unwrap(memberExpression.Expression); + } + + if (current is not ParameterExpression || members.Count == 0) + { + throw Unsupported(selector); + } + + members.Reverse(); // root → leaf + + var segments = new string[members.Count]; + for (var i = 0; i < members.Count; i++) + { + var member = members[i]; + var memberType = GetMemberType(member); + + if (!allowSecretMembers && IsSecretType(memberType)) + { + throw new NotSupportedException( + $"Member '{member.Name}' is a secret and cannot be set as plaintext via LocalStorage. " + + "Use SetSecretAsync with a pre-encrypted envelope, or manage secrets via the Secrets CLI/provider."); + } + + segments[i] = ResolveJsonName(member); + } + + return string.Join('.', segments); + } + + private static Expression? Unwrap(Expression? expression) + => expression is UnaryExpression { NodeType: ExpressionType.Convert or ExpressionType.ConvertChecked } unary + ? unary.Operand + : expression; + + private static string ResolveJsonName(MemberInfo member) + => member.GetCustomAttribute()?.Name ?? member.Name; + + private static Type GetMemberType(MemberInfo member) => member switch + { + PropertyInfo property => property.PropertyType, + FieldInfo field => field.FieldType, + _ => throw new NotSupportedException($"Unsupported member kind: {member.GetType().Name}."), + }; + + private static bool IsSecretType(Type type) + { + if (!type.IsGenericType) + { + return false; + } + + var definition = type.GetGenericTypeDefinition(); + return definition == typeof(Secret<>) || definition == typeof(ISecret<>); + } + + /// + /// True if is a secret, or contains a secret anywhere in its object graph + /// (nested property/field, collection element, array element). Used to reject plaintext writes of + /// objects that carry secrets — those must be set per-leaf via SetSecretAsync with an encrypted envelope. + /// + internal static bool ContainsSecret(Type type) => ContainsSecret(type, new HashSet()); + + private static bool ContainsSecret(Type? type, HashSet visited) + { + if (type is null || !visited.Add(type)) + { + return false; + } + + if (IsSecretType(type)) + { + return true; + } + + var underlying = Nullable.GetUnderlyingType(type); + if (underlying is not null) + { + return ContainsSecret(underlying, visited); + } + + if (type.IsArray && ContainsSecret(type.GetElementType(), visited)) + { + return true; + } + + // Generic arguments cover collections/dictionaries (List>, Dictionary>, …). + foreach (var arg in type.GetGenericArguments()) + { + if (ContainsSecret(arg, visited)) + { + return true; + } + } + + // Don't walk the members of BCL/primitive types — their generic args (handled above) are enough. + if (type.IsPrimitive || type.IsEnum || type == typeof(string) || type.Namespace?.StartsWith("System", StringComparison.Ordinal) == true) + { + return false; + } + + const BindingFlags flags = BindingFlags.Public | BindingFlags.Instance; + foreach (var property in type.GetProperties(flags)) + { + if (property.GetIndexParameters().Length == 0 && ContainsSecret(property.PropertyType, visited)) + { + return true; + } + } + + foreach (var field in type.GetFields(flags)) + { + if (ContainsSecret(field.FieldType, visited)) + { + return true; + } + } + + return false; + } + + private static NotSupportedException Unsupported(Expression> selector) + => new( + $"Selector '{selector}' is not supported. Only simple member-access chains are allowed " + + "(no method calls, indexers, or array element access). " + + "Use the raw Overlay surface for dynamic key paths."); +} diff --git a/src/Cocoar.Configuration/Providers/LocalStorageProvider/OverlaySerialization.cs b/src/Cocoar.Configuration/Providers/LocalStorageProvider/OverlaySerialization.cs new file mode 100644 index 0000000..5db80b9 --- /dev/null +++ b/src/Cocoar.Configuration/Providers/LocalStorageProvider/OverlaySerialization.cs @@ -0,0 +1,44 @@ +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Text.Json.Serialization; + +namespace Cocoar.Configuration.Providers; + +/// +/// JSON options for reading and writing LocalStorage overlay values. +/// +/// These are deliberately vanilla: the configuration pipeline's options carry custom converters +/// (notably StringToPrimitiveConverter<T>, whose Write re-enters with the same options and +/// stack-overflows on bare primitives). For overlay writes we need only enum-as-string round-tripping so the +/// values match what the case-insensitive pipeline deserializer reads back. +/// +/// +internal static class OverlaySerialization +{ + /// Options for serializing an override value to a sparse leaf (no pipeline converters — Trap A). + internal static readonly JsonSerializerOptions WriteOptions = CreateOptions(); + + /// Options for deserializing the sparse overlay back into a partial T (case-insensitive keys). + internal static readonly JsonSerializerOptions ReadOptions = CreateReadOptions(); + + private static JsonSerializerOptions CreateOptions() + { + var options = new JsonSerializerOptions(); + options.Converters.Add(new JsonStringEnumConverter()); + return options; + } + + private static JsonSerializerOptions CreateReadOptions() + { + var options = new JsonSerializerOptions { PropertyNameCaseInsensitive = true }; + options.Converters.Add(new JsonStringEnumConverter()); + return options; + } + + /// + /// Serializes a typed override value to a . A reference + /// produces a node, which the overlay persists as an explicit JSON null. + /// + internal static JsonNode? SerializeValue(TValue value) + => JsonSerializer.SerializeToNode(value, WriteOptions); +} diff --git a/src/Cocoar.Configuration/Providers/LocalStorageProvider/SparseOverlayMutator.cs b/src/Cocoar.Configuration/Providers/LocalStorageProvider/SparseOverlayMutator.cs new file mode 100644 index 0000000..6a5bbc9 --- /dev/null +++ b/src/Cocoar.Configuration/Providers/LocalStorageProvider/SparseOverlayMutator.cs @@ -0,0 +1,152 @@ +using System.Text; +using System.Text.Json.Nodes; +using Cocoar.Json.Mutable; + +namespace Cocoar.Configuration.Providers; + +/// +/// Applies sparse mutations to the persisted overlay bytes: setting a single leaf (creating intermediate +/// objects on demand) and removing a leaf (pruning emptied ancestors). Key casing is aligned to the existing +/// overlay first, then to the base layers (Trap B), so an override lands byte-identically on the lower-layer +/// key and acts as an override rather than a sibling. +/// +internal static class SparseOverlayMutator +{ + /// + /// Returns new overlay bytes with set at . + /// A writes an explicit JSON-null leaf. + /// + internal static byte[] Set(byte[] currentBytes, string keyPath, JsonNode? valueNode, MutableJsonObject? baseDom) + { + var root = MutableJsonDocument.Parse(currentBytes) as MutableJsonObject ?? new MutableJsonObject(); + var segments = keyPath.Split('.'); + + var parent = root; + var baseObj = baseDom; + + for (var i = 0; i < segments.Length - 1; i++) + { + var name = ResolveName(parent, baseObj, segments[i]); + + if (TryGetChild(parent, name) is not MutableJsonObject child) + { + child = new MutableJsonObject(); + parent.Set(name, child); + } + + parent = child; + // Descend the base by the BASE's own key for this segment (case-insensitive), independent of the + // name chosen for the overlay — so leaf casing keeps aligning to the base even if an intermediate + // overlay key has drifted from the base's casing (Trap B, resolve against the base position). + baseObj = DescendBase(baseObj, segments[i]); + } + + var leafName = ResolveName(parent, baseObj, segments[^1]); + parent.Set(leafName, ToMutable(valueNode)); + + return MutableJsonDocument.ToUtf8Bytes(root); + } + + /// + /// Returns new overlay bytes with the leaf at removed and any ancestors that + /// became empty pruned. The boolean indicates whether anything was removed (idempotent no-op if absent). + /// + internal static (byte[] Bytes, bool Removed) Remove(byte[] currentBytes, string keyPath) + { + if (MutableJsonDocument.Parse(currentBytes) is not MutableJsonObject root) + { + return (currentBytes, false); + } + + var segments = keyPath.Split('.'); + var chain = new List<(MutableJsonObject Parent, string Name)>(segments.Length - 1); + var parent = root; + + for (var i = 0; i < segments.Length - 1; i++) + { + var name = FindExistingName(parent, segments[i]); + if (name is null || TryGetChild(parent, name) is not MutableJsonObject child) + { + return (currentBytes, false); // path not present → not overridden + } + + chain.Add((parent, name)); + parent = child; + } + + var leafName = FindExistingName(parent, segments[^1]); + if (leafName is null) + { + return (currentBytes, false); + } + + parent.Remove(leafName); + + // Prune empty ancestors bottom-up so ReadOverlay / provenance stays clean. + for (var i = chain.Count - 1; i >= 0; i--) + { + var (ancestor, name) = chain[i]; + if (TryGetChild(ancestor, name) is MutableJsonObject obj && obj.Properties.Count == 0) + { + ancestor.Remove(name); + } + else + { + break; + } + } + + return (MutableJsonDocument.ToUtf8Bytes(root), true); + } + + private static MutableJsonNode ToMutable(JsonNode? node) + => node is null + ? MutableJsonNull.Instance + : MutableJsonDocument.Parse(Encoding.UTF8.GetBytes(node.ToJsonString())); + + /// + /// Resolves the exact key name to write: reuse an existing overlay key (avoid casing-variant siblings), + /// else reuse the base layer's key casing (Trap B), else fall back to the supplied default. + /// + private static string ResolveName(MutableJsonObject overlayParent, MutableJsonObject? baseObj, string defaultName) + => FindExistingName(overlayParent, defaultName) + ?? (baseObj is null ? null : FindExistingName(baseObj, defaultName)) + ?? defaultName; + + private static MutableJsonObject? DescendBase(MutableJsonObject? baseObj, string segment) + { + if (baseObj is null) + { + return null; + } + + var baseName = FindExistingName(baseObj, segment); + return baseName is not null ? TryGetChild(baseObj, baseName) as MutableJsonObject : null; + } + + private static string? FindExistingName(MutableJsonObject obj, string name) + { + foreach (var property in obj.Properties) + { + if (string.Equals(property.Name, name, StringComparison.OrdinalIgnoreCase)) + { + return property.Name; + } + } + + return null; + } + + private static MutableJsonNode? TryGetChild(MutableJsonObject obj, string exactName) + { + foreach (var property in obj.Properties) + { + if (string.Equals(property.Name, exactName, StringComparison.Ordinal)) + { + return property.Value; + } + } + + return null; + } +} diff --git a/src/Cocoar.Configuration/Rules/AggregateRuleManager.cs b/src/Cocoar.Configuration/Rules/AggregateRuleManager.cs index 4a69955..f2ff66e 100644 --- a/src/Cocoar.Configuration/Rules/AggregateRuleManager.cs +++ b/src/Cocoar.Configuration/Rules/AggregateRuleManager.cs @@ -1,5 +1,6 @@ using Cocoar.Configuration.Core; using Cocoar.Configuration.Infrastructure; +using Cocoar.Configuration.Providers.Abstractions; using Cocoar.Configuration.Reactive.Internal; using Cocoar.Json.Mutable; using Microsoft.Extensions.Logging; @@ -66,6 +67,8 @@ public AggregateRuleManager(AggregateConfigRule rule, ILogger logger, ProviderRe public IReadOnlyList? SubManagers => _internalManagers; + public ConfigurationProvider? CurrentProvider => null; + public async Task?> ComputeAsync(IConfigurationAccessor accessor, CancellationToken ct) { LastFailureException = null; diff --git a/src/Cocoar.Configuration/Rules/IRuleManager.cs b/src/Cocoar.Configuration/Rules/IRuleManager.cs index 878076a..85b00a8 100644 --- a/src/Cocoar.Configuration/Rules/IRuleManager.cs +++ b/src/Cocoar.Configuration/Rules/IRuleManager.cs @@ -1,4 +1,5 @@ using Cocoar.Configuration.Core; +using Cocoar.Configuration.Providers.Abstractions; using Cocoar.Json.Mutable; namespace Cocoar.Configuration.Rules; @@ -30,6 +31,13 @@ internal interface IRuleManager : IDisposable MutableJsonObject? LastJsonContribution { get; set; } string? LastSelectionHash { get; set; } + /// + /// The provider instance currently held by this rule, or if not yet acquired + /// (or for aggregates, which have no single provider). Used by overlay providers (e.g. LocalStorage) + /// to locate their own layer in the pipeline by provider/store reference. + /// + ConfigurationProvider? CurrentProvider { get; } + Task?> ComputeAsync(IConfigurationAccessor accessor, CancellationToken ct); void ClearCachedBytes(); diff --git a/src/Cocoar.Configuration/Rules/RuleManager.cs b/src/Cocoar.Configuration/Rules/RuleManager.cs index a085123..b7ad68d 100644 --- a/src/Cocoar.Configuration/Rules/RuleManager.cs +++ b/src/Cocoar.Configuration/Rules/RuleManager.cs @@ -58,6 +58,8 @@ public string? LastSelectionHash public IReadOnlyList? SubManagers => null; + public ConfigurationProvider? CurrentProvider => _providerLease.Provider; + public RuleManager(ConfigRule rule, ILogger logger, ProviderRegistry registry) { _rule = rule; diff --git a/src/Cocoar.Configuration/Secrets/Core/ISecretEncryptionKeyInfoProvider.cs b/src/Cocoar.Configuration/Secrets/Core/ISecretEncryptionKeyInfoProvider.cs new file mode 100644 index 0000000..56fea7c --- /dev/null +++ b/src/Cocoar.Configuration/Secrets/Core/ISecretEncryptionKeyInfoProvider.cs @@ -0,0 +1,14 @@ +using Cocoar.Configuration.Secrets.SecretTypes; + +namespace Cocoar.Configuration.Secrets.Core; + +/// +/// Internal capability composed beside a secrets protector. Exposes the current encryption public key +/// for the protector's kid, re-read from the underlying certificate source on every call so rotation +/// is reflected. Composed only where a single publishable encryption kid is unambiguous. +/// +internal interface ISecretEncryptionKeyInfoProvider +{ + /// The current encryption public key, or when none is available. + SecretEncryptionPublicKey? TryGetCurrentKey(); +} diff --git a/src/Cocoar.Configuration/Secrets/Core/SecretEncryptionKeyProvider.cs b/src/Cocoar.Configuration/Secrets/Core/SecretEncryptionKeyProvider.cs new file mode 100644 index 0000000..645c08d --- /dev/null +++ b/src/Cocoar.Configuration/Secrets/Core/SecretEncryptionKeyProvider.cs @@ -0,0 +1,63 @@ +using Cocoar.Configuration.Core; +using Cocoar.Configuration.Secrets.SecretTypes; + +namespace Cocoar.Configuration.Secrets.Core; + +/// +/// Public-facing . Resolves the composed +/// capabilities lazily on every call — so certificate +/// rotation is reflected and no stale snapshot is held — and aggregates one current key per kid. +/// +internal sealed class SecretEncryptionKeyProvider : ISecretEncryptionKeyProvider +{ + private readonly ConfigManagerCapabilityScope _scope; + + public SecretEncryptionKeyProvider(ConfigManagerCapabilityScope scope) + => _scope = scope ?? throw new ArgumentNullException(nameof(scope)); + + public IReadOnlyList GetCurrentKeys() + { + var composition = _scope.Owner.GetComposition(); + var infoProviders = composition?.GetAll(); + if (infoProviders is null || infoProviders.Count == 0) + return Array.Empty(); + + // One key per kid. On a kid collision the VALUE is last-writer-wins (matching the decrypt + // resolver's recency preference); the emitted list POSITION is first-appearance. Collisions + // cannot occur in the current single-kid publishing path — revisit ordering for multi-kid. + var byKid = new Dictionary(StringComparer.Ordinal); + var order = new List(); + foreach (var info in infoProviders) + { + var key = info.TryGetCurrentKey(); + if (key is null) + continue; + + if (!byKid.ContainsKey(key.Kid)) + order.Add(key.Kid); + byKid[key.Kid] = key; + } + + if (order.Count == 0) + return Array.Empty(); + + var result = new List(order.Count); + foreach (var kid in order) + result.Add(byKid[kid]); + return result; + } + + public SecretEncryptionPublicKey? GetCurrentKey(string kid) + { + if (string.IsNullOrEmpty(kid)) + return null; + + foreach (var key in GetCurrentKeys()) + { + if (string.Equals(key.Kid, kid, StringComparison.Ordinal)) + return key; + } + + return null; + } +} diff --git a/src/Cocoar.Configuration/Secrets/Protectors/Hybrid/CertificateInventory.cs b/src/Cocoar.Configuration/Secrets/Protectors/Hybrid/CertificateInventory.cs index 86a46ab..7d6810b 100644 --- a/src/Cocoar.Configuration/Secrets/Protectors/Hybrid/CertificateInventory.cs +++ b/src/Cocoar.Configuration/Secrets/Protectors/Hybrid/CertificateInventory.cs @@ -202,6 +202,50 @@ public bool TryDecryptWithKid(HybridEnvelope envelope, string kid, out byte[] pl } } + /// + /// Exports the SubjectPublicKeyInfo (DER) of the certificate the decryption engine prefers + /// (the first in the current ordering) — for publishing as the encryption public key. Returns + /// only public-key bytes; the is never exposed. Returns + /// when no usable RSA certificate is present. Runs under the write lock + /// because mutates the cache. + /// + internal byte[]? TryExportPreferredPublicKey() + { + _lock.EnterWriteLock(); + try + { + foreach (var certPath in _sortedCertPaths) + { + X509Certificate2 cert; + try + { + cert = GetOrLoadCertificate(certPath); + } + catch (Exception ex) when ( + ex is CryptographicException or IOException or UnauthorizedAccessException + or InvalidOperationException or NotSupportedException) + { + // A cert that can't be loaded right now — e.g. a transient file race during + // rotation (delete+recreate / locked / partially written) or a non-usable file — + // is skipped so publishing degrades gracefully (empty result) instead of a 500. + continue; + } + + using var rsa = cert.GetRSAPublicKey(); + if (rsa is null) + continue; + + return rsa.ExportSubjectPublicKeyInfo(); + } + + return null; + } + finally + { + _lock.ExitWriteLock(); + } + } + private bool TryDecryptWithCert(string certPath, HybridEnvelope envelope, out byte[] plaintext) { try diff --git a/src/Cocoar.Configuration/Secrets/Protectors/Hybrid/HybridProtectorRegistrar.cs b/src/Cocoar.Configuration/Secrets/Protectors/Hybrid/HybridProtectorRegistrar.cs index 3502320..5bdf352 100644 --- a/src/Cocoar.Configuration/Secrets/Protectors/Hybrid/HybridProtectorRegistrar.cs +++ b/src/Cocoar.Configuration/Secrets/Protectors/Hybrid/HybridProtectorRegistrar.cs @@ -28,6 +28,17 @@ private void RegisterProtector(IRuntimeSecretDecryptor protector) recomposer.Build(); } + private void RegisterProtectorAndKeyInfo(IRuntimeSecretDecryptor protector, ISecretEncryptionKeyInfoProvider keyInfo) + { + var composition = _capabilityScope.Owner.GetComposition(); + if (composition == null) return; + + var recomposer = _capabilityScope.Recompose(composition); + recomposer.AddAs(protector); + recomposer.AddAs(keyInfo); + recomposer.Build(); + } + /// /// Unified method to apply certificate protector configuration. /// @@ -68,7 +79,11 @@ private void ApplySingleKidMode(CertificateProtectorConfig config, IConfiguratio // In single-kid mode with the new architecture, we create a kid folder on the fly // or we need to handle this differently. Actually, let's just register for the single kid. var protector = new SingleKidProtectorWrapper(inventory, config.ForceSingleKid!, config.AdditionalKids); - RegisterProtector(protector); + + // Publish the current encryption public key for this single, unambiguous kid. + // (Multi-kid / folder mode is decrypt-only here; per-tenant publishing comes with multi-tenancy.) + var keyInfo = new InventoryKeyInfoProvider(inventory, config.ForceSingleKid!); + RegisterProtectorAndKeyInfo(protector, keyInfo); } private void ApplyMultiKidMode(CertificateProtectorConfig config, IConfigurationAccessor configAccessor) diff --git a/src/Cocoar.Configuration/Secrets/Protectors/Hybrid/InventoryKeyInfoProvider.cs b/src/Cocoar.Configuration/Secrets/Protectors/Hybrid/InventoryKeyInfoProvider.cs new file mode 100644 index 0000000..53cf733 --- /dev/null +++ b/src/Cocoar.Configuration/Secrets/Protectors/Hybrid/InventoryKeyInfoProvider.cs @@ -0,0 +1,37 @@ +using Cocoar.Configuration.Secrets.Core; +using Cocoar.Configuration.Secrets.SecretTypes; + +namespace Cocoar.Configuration.Secrets.Protectors.Hybrid; + +/// +/// Builds the current encryption public key from a for a single +/// configured kid. Exports only public-key bytes (SPKI), base64url-encoded without padding — the +/// same codec the cocoar.secret envelope wire format uses. +/// +internal sealed class InventoryKeyInfoProvider : ISecretEncryptionKeyInfoProvider +{ + private readonly CertificateInventory _inventory; + private readonly string _kid; + + public InventoryKeyInfoProvider(CertificateInventory inventory, string kid) + { + _inventory = inventory ?? throw new ArgumentNullException(nameof(inventory)); + _kid = kid ?? throw new ArgumentNullException(nameof(kid)); + } + + public SecretEncryptionPublicKey? TryGetCurrentKey() + { + var spki = _inventory.TryExportPreferredPublicKey(); + if (spki is null) + return null; + + return new SecretEncryptionPublicKey + { + Kid = _kid, + PublicKey = ToBase64Url(spki), + }; + } + + private static string ToBase64Url(byte[] bytes) + => Convert.ToBase64String(bytes).Replace('+', '-').Replace('/', '_').TrimEnd('='); +} diff --git a/src/Examples/LocalStorageOverride/LocalStorageOverride.csproj b/src/Examples/LocalStorageOverride/LocalStorageOverride.csproj new file mode 100644 index 0000000..39f07f2 --- /dev/null +++ b/src/Examples/LocalStorageOverride/LocalStorageOverride.csproj @@ -0,0 +1,16 @@ + + + Exe + net9.0 + enable + enable + true + Examples.LocalStorageOverride + + + + + + + + diff --git a/src/Examples/LocalStorageOverride/Program.cs b/src/Examples/LocalStorageOverride/Program.cs new file mode 100644 index 0000000..30bd0ed --- /dev/null +++ b/src/Examples/LocalStorageOverride/Program.cs @@ -0,0 +1,92 @@ +using System.Diagnostics; +using Cocoar.Configuration.Core; +using Cocoar.Configuration.DI; +using Cocoar.Configuration.LocalStorage; +using Cocoar.Configuration.Providers; +using Microsoft.Extensions.DependencyInjection; + +namespace Examples.LocalStorageOverride; + +// Demonstrates LocalStorage as a SPARSE OVERRIDE OVERLAY: +// - lower layers (here, a static JSON layer) supply the DEFAULTS +// - the application overrides INDIVIDUAL values at runtime via ILocalStorage +// - only the overridden keys are persisted; everything else keeps inheriting +// - reset removes an override so the value falls back to the default again +public static class Program +{ + public sealed class SmtpSettings + { + public string? Host { get; set; } + public int Port { get; set; } + public bool UseSsl { get; set; } + } + + public static async Task Main() + { + var services = new ServiceCollection(); + services.AddCocoarConfiguration(c => c.UseConfiguration(rules => + [ + // Defaults supplied by the normal sources (a file/env/etc. — a static layer here): + rules.For().FromStaticJson("""{ "Host": "smtp.default.com", "Port": 25, "UseSsl": false }"""), + // The app-controlled override layer, placed AFTER so it wins for the keys it sets: + rules.For().FromLocalStorage(), + ])); + + using var provider = services.BuildServiceProvider(); + var manager = provider.GetRequiredService(); + var storage = provider.GetRequiredService>(); + + // Start from a clean overlay so the demo is deterministic across runs. + await storage.ClearAsync(); + await WaitUntilAsync(() => manager.GetConfig()!.Port == 25); + Print("Defaults (overlay empty)", manager.GetConfig()!); + + // Override a single value — only "Port" is persisted; Host/UseSsl keep inheriting. + await storage.SetAsync(x => x.Port, 587); + await WaitUntilAsync(() => manager.GetConfig()!.Port == 587); + Print("After SetAsync(x => x.Port, 587)", manager.GetConfig()!); + + await storage.SetAsync(x => x.UseSsl, true); + await WaitUntilAsync(() => manager.GetConfig()!.UseSsl); + Print("After SetAsync(x => x.UseSsl, true)", manager.GetConfig()!); + + // The raw stored overlay is sparse — only the two keys we set. + Console.WriteLine($"\nPersisted overlay (sparse): {await storage.Overlay.ReadOverlayAsync()}"); + + // Provenance for a management UI: base vs. effective vs. overridden, per key. + Console.WriteLine("\nDescribeAsync() — per-key provenance:"); + foreach (var entry in await storage.DescribeAsync()) + { + Console.WriteLine( + $" {entry.KeyPath,-8} base={Render(entry.BaseValue),-20} " + + $"effective={Render(entry.EffectiveValue),-20} overridden={entry.IsOverridden}"); + } + + // Reset one override — the value falls back to the default. + await storage.ResetAsync(x => x.Port); + await WaitUntilAsync(() => manager.GetConfig()!.Port == 25); + Print("\nAfter ResetAsync(x => x.Port)", manager.GetConfig()!); + + // Clear everything — back to pure defaults. + await storage.ClearAsync(); + await WaitUntilAsync(() => !manager.GetConfig()!.UseSsl); + Print("After ClearAsync()", manager.GetConfig()!); + } + + private static void Print(string label, SmtpSettings s) => + Console.WriteLine($"{label,-40} Host={s.Host}, Port={s.Port}, UseSsl={s.UseSsl}"); + + private static string Render(System.Text.Json.JsonElement? value) => + value is { } v ? v.GetRawText() : "(absent)"; + + // Writes trigger a debounced recompute; poll briefly until the effective value reflects it. + private static async Task WaitUntilAsync(Func condition) + { + var sw = Stopwatch.StartNew(); + while (sw.Elapsed < TimeSpan.FromSeconds(3)) + { + if (condition()) return; + await Task.Delay(25); + } + } +} diff --git a/src/tests/Cocoar.Configuration.AspNetCore.Tests/SecretEncryptionKeyEndpointTests.cs b/src/tests/Cocoar.Configuration.AspNetCore.Tests/SecretEncryptionKeyEndpointTests.cs new file mode 100644 index 0000000..a701259 --- /dev/null +++ b/src/tests/Cocoar.Configuration.AspNetCore.Tests/SecretEncryptionKeyEndpointTests.cs @@ -0,0 +1,214 @@ +using System.Net; +using System.Net.Http.Json; +using System.Security.Cryptography; +using System.Text.Encodings.Web; +using System.Text.Json; +using Cocoar.Configuration.AspNetCore; +using Cocoar.Configuration.DI; +using Cocoar.Configuration.Secrets; +using Cocoar.Configuration.X509Encryption; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Xunit; + +namespace Cocoar.Configuration.AspNetCore.Tests; + +public class SecretEncryptionKeyEndpointTests : IAsyncDisposable +{ + private IHost? _host; + private HttpClient? _client; + private string? _pfxPath; + + private const string BasePattern = "/.well-known/cocoar/encryption-keys"; + + /// Host with single-kid secrets configured (so a key is publishable). + private async Task CreateHostWithSecrets( + string kid, + Action? configureJson = null) + { + _pfxPath = Path.Combine(Path.GetTempPath(), "cocoar_ep_" + Guid.NewGuid().ToString("N") + ".pfx"); + using (X509CertificateGenerator.GenerateAndSavePfx(_pfxPath, password: null, "CN=Cocoar Test", overwrite: true)) { } + + var builder = WebApplication.CreateBuilder(); + builder.Services.AddCocoarConfiguration(c => c + .UseConfiguration(rules => []) + .UseSecretsSetup(secrets => secrets.UseCertificateFromFile(_pfxPath).WithKeyId(kid))); + if (configureJson is not null) + builder.Services.Configure(configureJson); + builder.WebHost.UseTestServer(); + + var app = builder.Build(); + app.MapSecretEncryptionKeyEndpoints(); + return await StartAsync(app); + } + + /// Host with NO secrets configured (the accessor service is not registered). + private async Task CreateHostWithoutSecrets() + { + var builder = WebApplication.CreateBuilder(); + builder.Services.AddCocoarConfiguration(c => c.UseConfiguration(rules => [])); + builder.WebHost.UseTestServer(); + + var app = builder.Build(); + app.MapSecretEncryptionKeyEndpoints(); + return await StartAsync(app); + } + + /// Host that gates both routes behind authorization with a deny-all scheme. + private async Task CreateHostWithAuth() + { + var builder = WebApplication.CreateBuilder(); + builder.Services.AddCocoarConfiguration(c => c.UseConfiguration(rules => [])); + builder.Services + .AddAuthentication("Deny") + .AddScheme("Deny", _ => { }); + builder.Services.AddAuthorization(); + builder.WebHost.UseTestServer(); + + var app = builder.Build(); + app.UseAuthentication(); + app.UseAuthorization(); + app.MapSecretEncryptionKeyEndpoints().RequireAuthorization(); + return await StartAsync(app); + } + + private async Task StartAsync(WebApplication app) + { + await app.StartAsync(); + _host = app; + _client = app.GetTestClient(); + return _client; + } + + public async ValueTask DisposeAsync() + { + _client?.Dispose(); + if (_host != null) await _host.StopAsync(); + _host?.Dispose(); + if (_pfxPath != null && File.Exists(_pfxPath)) File.Delete(_pfxPath); + GC.SuppressFinalize(this); + } + + [Fact] + public async Task List_WithSingleKidSecrets_Returns200WithOneKey() + { + const string kid = "endpoint-kid"; + var client = await CreateHostWithSecrets(kid); + + var response = await client.GetAsync(BasePattern); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var json = await response.Content.ReadFromJsonAsync(); + var keys = json.GetProperty("keys"); + Assert.Equal(1, keys.GetArrayLength()); + + var key = keys[0]; + Assert.Equal(kid, key.GetProperty("kid").GetString()); + Assert.Equal("spki", key.GetProperty("format").GetString()); + Assert.Equal("base64url", key.GetProperty("encoding").GetString()); + var publicKey = key.GetProperty("publicKey").GetString()!; + Assert.False(string.IsNullOrEmpty(publicKey)); + Assert.DoesNotContain('=', publicKey); + } + + [Fact] + public async Task ByKid_KnownKid_Returns200() + { + const string kid = "by-kid"; + var client = await CreateHostWithSecrets(kid); + + var response = await client.GetAsync($"{BasePattern}/{kid}"); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var json = await response.Content.ReadFromJsonAsync(); + Assert.Equal(kid, json.GetProperty("kid").GetString()); + Assert.False(string.IsNullOrEmpty(json.GetProperty("publicKey").GetString())); + } + + [Fact] + public async Task ByKid_UnknownKid_Returns404() + { + var client = await CreateHostWithSecrets("configured-kid"); + + var response = await client.GetAsync($"{BasePattern}/some-other-kid"); + + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + } + + [Fact] + public async Task List_NoSecrets_Returns200WithEmptyKeys() + { + var client = await CreateHostWithoutSecrets(); + + var response = await client.GetAsync(BasePattern); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var json = await response.Content.ReadFromJsonAsync(); + Assert.Equal(0, json.GetProperty("keys").GetArrayLength()); + } + + [Fact] + public async Task ByKid_NoSecrets_Returns404() + { + var client = await CreateHostWithoutSecrets(); + + var response = await client.GetAsync($"{BasePattern}/anything"); + + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + } + + [Fact] + public async Task RequireAuthorization_GatesBothRoutes() + { + var client = await CreateHostWithAuth(); + + var list = await client.GetAsync(BasePattern); + var byKid = await client.GetAsync($"{BasePattern}/any"); + + // A single .RequireAuthorization() on the composite builder must apply to BOTH routes. + Assert.True( + list.StatusCode is HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden, + $"list expected 401/403 but got {(int)list.StatusCode}"); + Assert.True( + byKid.StatusCode is HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden, + $"by-kid expected 401/403 but got {(int)byKid.StatusCode}"); + } + + [Fact] + public async Task List_WithCustomJsonNamingPolicy_KeepsPinnedFieldNames() + { + const string kid = "policy-kid"; + var client = await CreateHostWithSecrets( + kid, + o => o.SerializerOptions.PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseUpper); + + var response = await client.GetAsync(BasePattern); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var raw = await response.Content.ReadAsStringAsync(); + + // The list wrapper ("keys") and every record field are pinned via [JsonPropertyName], + // so a non-default host naming policy must NOT rename them. + Assert.Contains("\"keys\"", raw); + Assert.Contains("\"kid\"", raw); + Assert.Contains("\"publicKey\"", raw); + Assert.DoesNotContain("KEYS", raw); + Assert.DoesNotContain("PUBLIC_KEY", raw); + } + + private sealed class DenyAllHandler : AuthenticationHandler + { + public DenyAllHandler(IOptionsMonitor options, ILoggerFactory logger, UrlEncoder encoder) + : base(options, logger, encoder) + { + } + + protected override Task HandleAuthenticateAsync() + => Task.FromResult(AuthenticateResult.NoResult()); + } +} diff --git a/src/tests/Cocoar.Configuration.Providers.Tests/LocalStorage/FileStorageBackendTests.cs b/src/tests/Cocoar.Configuration.Providers.Tests/LocalStorage/FileStorageBackendTests.cs new file mode 100644 index 0000000..8b464e6 --- /dev/null +++ b/src/tests/Cocoar.Configuration.Providers.Tests/LocalStorage/FileStorageBackendTests.cs @@ -0,0 +1,95 @@ +using System.Text; +using System.Text.Json; +using Cocoar.Configuration.Providers; +using Cocoar.Configuration.Providers.Tests.TestUtilities; +using Xunit; + +namespace Cocoar.Configuration.Providers.Tests.LocalStorage; + +[Trait("Type", "Unit")] +public class FileStorageBackendTests +{ + [Fact] + public async Task ReadAsync_MissingKey_ReturnsNull() + { + using var dir = TempDirectoryHelper.Create(); + var backend = new FileStorageBackend(dir.Path); + + Assert.Null(await backend.ReadAsync("nope")); + } + + [Fact] + public async Task WriteThenRead_RoundTrips() + { + using var dir = TempDirectoryHelper.Create(); + var backend = new FileStorageBackend(dir.Path); + var payload = Encoding.UTF8.GetBytes("{\"a\":1}"); + + await backend.WriteAsync("key", payload); + var read = await backend.ReadAsync("key"); + + Assert.NotNull(read); + Assert.Equal("{\"a\":1}", Encoding.UTF8.GetString(read!)); + } + + [Fact] + public async Task Write_LeavesNoTempFiles() + { + using var dir = TempDirectoryHelper.Create(); + var backend = new FileStorageBackend(dir.Path); + + await backend.WriteAsync("key", Encoding.UTF8.GetBytes("{\"a\":1}")); + + Assert.Empty(Directory.GetFiles(dir.Path, "*.tmp")); + } + + [Fact] + public async Task ConcurrentWrites_NeverCorrupt() + { + using var dir = TempDirectoryHelper.Create(); + var backend = new FileStorageBackend(dir.Path); + + // Direct, unsynchronized concurrent writes to the same key. The per-write GUID temp guarantees no + // writer clobbers another's intermediate file, so the destination is always a complete document. + // (In production the store's write lock serializes writes; an overwrite move can still race + // transiently on Windows, which we tolerate — we assert no corruption, not zero races.) + var writes = Enumerable.Range(0, 50).Select(async i => + { + try + { + await backend.WriteAsync("key", Encoding.UTF8.GetBytes($"{{\"v\":{i}}}")); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + // Transient overwrite-move race under pathological concurrency; not corruption. + } + }); + await Task.WhenAll(writes); + + var read = await backend.ReadAsync("key"); + Assert.NotNull(read); + + // Whatever ordering won, the persisted bytes must be a single, valid, uncorrupted JSON document... + using var doc = JsonDocument.Parse(read!); + Assert.True(doc.RootElement.TryGetProperty("v", out _)); + // ...and every per-write temp file must have been moved or cleaned up. + Assert.Empty(Directory.GetFiles(dir.Path, "*.tmp")); + } + + [Fact] + public async Task ConcurrentStoreWrites_AreSerializedAndConsistent() + { + using var dir = TempDirectoryHelper.Create(); + using var store = new LocalStorageStore(new FileStorageBackend(dir.Path), "key"); + + // The real production path: writes go through the store's write lock and never throw or corrupt. + var writes = Enumerable.Range(0, 50).Select(i => + store.WriteBytesAsync(Encoding.UTF8.GetBytes($"{{\"v\":{i}}}"))); + await Task.WhenAll(writes); + + var read = await store.ReadBytesAsync(); + using var doc = JsonDocument.Parse(read); + Assert.True(doc.RootElement.TryGetProperty("v", out _)); + Assert.Empty(Directory.GetFiles(dir.Path, "*.tmp")); + } +} diff --git a/src/tests/Cocoar.Configuration.Providers.Tests/LocalStorage/LocalStorageOverlayEndToEndTests.cs b/src/tests/Cocoar.Configuration.Providers.Tests/LocalStorage/LocalStorageOverlayEndToEndTests.cs new file mode 100644 index 0000000..dc2d947 --- /dev/null +++ b/src/tests/Cocoar.Configuration.Providers.Tests/LocalStorage/LocalStorageOverlayEndToEndTests.cs @@ -0,0 +1,208 @@ +using Cocoar.Configuration.Core; +using Cocoar.Configuration.DI; +using Cocoar.Configuration.LocalStorage; +using Cocoar.Configuration.Providers; +using Cocoar.Configuration.Providers.Tests.TestUtilities; +using Cocoar.Configuration.Reactive; +using Cocoar.Configuration.Rules; +using Microsoft.Extensions.DependencyInjection; +using Xunit; + +namespace Cocoar.Configuration.Providers.Tests.LocalStorage; + +[Trait("Type", "Unit")] +public sealed class LocalStorageOverlayEndToEndTests : IDisposable +{ + private static readonly TimeSpan Timeout = TimeSpan.FromSeconds(5); + + private readonly List _disposables = new(); + + private (ServiceProvider Provider, ILocalStorage Storage, ConfigManager Manager) Build( + string baseJson, InMemoryBackend? backend = null) + { + backend ??= new InMemoryBackend(); + var file = TempFileHelper.Create(baseJson); + _disposables.Add(file); + + var services = new ServiceCollection(); + services.AddCocoarConfiguration(c => c.UseConfiguration(rules => new ConfigRule[] + { + rules.For().FromFile(file.FilePath).Required(), + rules.For().FromLocalStorage(backend), + })); + + var provider = services.BuildServiceProvider(); + _disposables.Add(provider); + + var storage = provider.GetRequiredService>(); + var manager = provider.GetRequiredService(); + return (provider, storage, manager); + } + + [Fact] + public async Task Set_OverridesOnlyTouchedKey_OthersInherit_PascalCaseBase() + { + var (_, storage, manager) = Build("{\"Host\":\"smtp.default.com\",\"Port\":25,\"UseSsl\":false}"); + + await storage.SetAsync(x => x.Port, 587); + + await ActiveWaitHelpers.WaitUntilAsync( + () => manager.GetConfig()!.Port == 587, Timeout, description: "Port override"); + + var config = manager.GetConfig()!; + Assert.Equal(587, config.Port); + Assert.Equal("smtp.default.com", config.Host); // inherited + Assert.False(config.UseSsl); // inherited + } + + [Fact] + public async Task Set_AlignsToCamelCaseBase_NoSiblingKey() + { + // Base file uses camelCase keys; the override must land on them (Trap B), not create PascalCase siblings. + var (_, storage, manager) = Build("{\"host\":\"smtp.default.com\",\"port\":25}"); + + await storage.SetAsync(x => x.Port, 587); + + await ActiveWaitHelpers.WaitUntilAsync( + () => manager.GetConfig()!.Port == 587, Timeout, description: "camelCase override"); + + var config = manager.GetConfig()!; + Assert.Equal(587, config.Port); + Assert.Equal("smtp.default.com", config.Host); // still inherited (no ambiguous duplicate key) + } + + [Fact] + public async Task Reset_RestoresInheritedValue() + { + var (_, storage, manager) = Build("{\"Port\":25}"); + + await storage.SetAsync(x => x.Port, 587); + await ActiveWaitHelpers.WaitUntilAsync( + () => manager.GetConfig()!.Port == 587, Timeout, description: "override applied"); + + var removed = await storage.ResetAsync(x => x.Port); + Assert.True(removed); + + await ActiveWaitHelpers.WaitUntilAsync( + () => manager.GetConfig()!.Port == 25, Timeout, description: "reset to base"); + } + + [Fact] + public async Task ExplicitNull_ClobbersBase_DistinctFromReset() + { + var (_, storage, manager) = Build("{\"Host\":\"smtp.default.com\"}"); + + await storage.SetAsync(x => x.Host, null); + await ActiveWaitHelpers.WaitUntilAsync( + () => manager.GetConfig()!.Host is null, Timeout, description: "explicit null clobber"); + + await storage.ResetAsync(x => x.Host); + await ActiveWaitHelpers.WaitUntilAsync( + () => manager.GetConfig()!.Host == "smtp.default.com", Timeout, description: "reset restores"); + } + + [Fact] + public async Task SetToDefaultValue_StillOverrides() + { + // Base Port is 25; overriding to the C# default (0) must persist and win — the headline correctness win. + var (_, storage, manager) = Build("{\"Port\":25}"); + + await storage.SetAsync(x => x.Port, 0); + + await ActiveWaitHelpers.WaitUntilAsync( + () => manager.GetConfig()!.Port == 0, Timeout, description: "default-valued override"); + } + + [Fact] + public async Task Write_EmitsReactiveUpdate() + { + var (provider, storage, _) = Build("{\"Port\":25}"); + var reactive = provider.GetRequiredService>(); + + SmtpSettings? latest = null; + using var subscription = reactive.Subscribe(v => latest = v); + + await storage.SetAsync(x => x.Port, 587); + + await ActiveWaitHelpers.WaitUntilAsync( + () => latest?.Port == 587, Timeout, description: "reactive emission"); + } + + [Fact] + public async Task DescribeAsync_ReportsBaseEffectiveAndOverridden() + { + var (_, storage, manager) = Build("{\"Host\":\"smtp.default.com\",\"Port\":25}"); + + await storage.SetAsync(x => x.Port, 587); + await ActiveWaitHelpers.WaitUntilAsync( + () => manager.GetConfig()!.Port == 587, Timeout, description: "override applied"); + + var entries = await storage.DescribeAsync(); + + var port = Assert.Single(entries, e => e.KeyPath == "Port"); + Assert.True(port.IsOverridden); + Assert.Equal(25, port.BaseValue!.Value.GetInt32()); + Assert.Equal(587, port.EffectiveValue!.Value.GetInt32()); + + var host = Assert.Single(entries, e => e.KeyPath == "Host"); + Assert.False(host.IsOverridden); + Assert.Equal("smtp.default.com", host.BaseValue!.Value.GetString()); + Assert.Equal("smtp.default.com", host.EffectiveValue!.Value.GetString()); + } + + [Fact] + public async Task ReadAsync_ReturnsSparsePartial_ReadOverlay_ReturnsRaw() + { + var (_, storage, _) = Build("{\"Host\":\"smtp.default.com\",\"Port\":25}"); + + Assert.Null(await storage.ReadAsync()); // nothing overridden yet + + await storage.SetAsync(x => x.Port, 587); + + var partial = await storage.ReadAsync(); + Assert.NotNull(partial); + Assert.Equal(587, partial!.Port); + Assert.Null(partial.Host); // unset in the sparse overlay => C# default + + var raw = await storage.Overlay.ReadOverlayAsync(); + Assert.NotNull(raw); + Assert.Equal(587, raw!["Port"]!.GetValue()); + } + + [Fact] + public async Task Clear_RemovesAllOverrides() + { + var (_, storage, manager) = Build("{\"Port\":25}"); + + await storage.SetAsync(x => x.Port, 587); + await ActiveWaitHelpers.WaitUntilAsync( + () => manager.GetConfig()!.Port == 587, Timeout, description: "override applied"); + + await storage.ClearAsync(); + await ActiveWaitHelpers.WaitUntilAsync( + () => manager.GetConfig()!.Port == 25, Timeout, description: "cleared to base"); + + Assert.Null(await storage.ReadAsync()); + } + + [Fact] + public void BothInterfaces_ResolveToSameSingletonInstance() + { + var (provider, storage, _) = Build("{\"Port\":25}"); + + var overlay = provider.GetRequiredService>(); + var storageAgain = provider.GetRequiredService>(); + + Assert.Same(storage, storageAgain); // singleton + Assert.Same(storage.Overlay, overlay); // overlay resolves to the same adapter + Assert.True(ReferenceEquals(storage, overlay)); // one object implements both + } + + public void Dispose() + { + for (var i = _disposables.Count - 1; i >= 0; i--) + { + try { _disposables[i].Dispose(); } catch { /* best effort */ } + } + } +} diff --git a/src/tests/Cocoar.Configuration.Providers.Tests/LocalStorage/LocalStorageSecretEnvelopeTests.cs b/src/tests/Cocoar.Configuration.Providers.Tests/LocalStorage/LocalStorageSecretEnvelopeTests.cs new file mode 100644 index 0000000..3bf58bb --- /dev/null +++ b/src/tests/Cocoar.Configuration.Providers.Tests/LocalStorage/LocalStorageSecretEnvelopeTests.cs @@ -0,0 +1,118 @@ +using System.Security.Cryptography.X509Certificates; +using System.Text.Json; +using System.Text.Json.Nodes; +using Cocoar.Configuration.Core; +using Cocoar.Configuration.DI; +using Cocoar.Configuration.LocalStorage; +using Cocoar.Configuration.Providers; +using Cocoar.Configuration.Providers.Tests.TestUtilities; +using Cocoar.Configuration.Rules; +using Cocoar.Configuration.Secrets; +using Cocoar.Configuration.Secrets.SecretTypes; +using Cocoar.Configuration.X509Encryption; +using Microsoft.Extensions.DependencyInjection; +using Xunit; + +namespace Cocoar.Configuration.Providers.Tests.LocalStorage; + +[Trait("Type", "Unit")] +public sealed class LocalStorageSecretEnvelopeTests +{ + public sealed class VaultConfig + { + public Secret? ApiKey { get; set; } + } + + [Fact] + public async Task SetSecretAsync_StoresEncryptedEnvelope_DecryptsToOriginalValue() + { + const string kid = "test-kid"; + var pfxPath = Path.Combine(Path.GetTempPath(), "cocoar_secret_" + Guid.NewGuid().ToString("N") + ".pfx"); + using var cert = X509CertificateGenerator.GenerateAndSavePfx(pfxPath, password: null, "CN=Cocoar Test", overwrite: true); + try + { + // Encrypt CLIENT-SIDE (here: with the cert) — the server only ever receives the envelope. + var envelope = BuildEnvelope(cert, kid, "super-secret-value"); + var backend = new InMemoryBackend(); + + var services = new ServiceCollection(); + services.AddCocoarConfiguration(c => c + .UseConfiguration(rules => new ConfigRule[] + { + rules.For().FromStaticJson("{}"), + rules.For().FromLocalStorage(backend), + }) + .UseSecretsSetup(secrets => secrets.UseCertificateFromFile(pfxPath).WithKeyId(kid))); + + using var provider = services.BuildServiceProvider(); + var storage = provider.GetRequiredService>(); + var manager = provider.GetRequiredService(); + + await storage.SetSecretAsync(x => x.ApiKey!, envelope); + + await ActiveWaitHelpers.WaitUntilAsync( + () => manager.GetConfig()?.ApiKey is not null, + TimeSpan.FromSeconds(5), description: "encrypted secret override applied"); + + var config = manager.GetConfig()!; + using var lease = config.ApiKey!.Open(); + Assert.Equal("super-secret-value", lease.Value); + } + finally + { + if (System.IO.File.Exists(pfxPath)) System.IO.File.Delete(pfxPath); + } + } + + [Fact] + public async Task SetSecretEnvelopeAsync_RejectsPlaintext() + { + using var provider = BuildMinimalProvider(); + var overlay = provider.GetRequiredService>(); + + // A bare string is not a cocoar.secret envelope → rejected before anything is stored. + await Assert.ThrowsAsync( + () => overlay.SetSecretEnvelopeAsync("ApiKey", JsonValue.Create("plaintext-leak"))); + } + + [Fact] + public void SetAsync_OnSecretMember_StillThrowsNotSupported() + { + using var provider = BuildMinimalProvider(); + var storage = provider.GetRequiredService>(); + + // The normal typed SetAsync must keep rejecting secret members (no plaintext into the overlay). + Assert.Throws( + () => { _ = storage.SetAsync(x => x.ApiKey!, Secret.FromPlain("x")); }); + } + + private static ServiceProvider BuildMinimalProvider() + { + var backend = new InMemoryBackend(); + var services = new ServiceCollection(); + services.AddCocoarConfiguration(c => c.UseConfiguration(rules => new ConfigRule[] + { + rules.For().FromStaticJson("{}"), + rules.For().FromLocalStorage(backend), + })); + return services.BuildServiceProvider(); + } + + private static SecretEnvelope BuildEnvelope(X509Certificate2 cert, string kid, string value) + { + var hybrid = new X509HybridCrypto(cert).Encrypt(JsonSerializer.Serialize(value)); + // The runtime decrypt path (HybridEnvelope byte[] fields) reads base64url WITHOUT padding — this is + // exactly what a browser must emit. (X509HybridCrypto produces standard base64, so we convert.) + return new SecretEnvelope + { + Kid = kid, + Wk = ToBase64Url(hybrid.WrappedKey), + Iv = ToBase64Url(hybrid.Iv), + Ct = ToBase64Url(hybrid.Ciphertext), + Tag = ToBase64Url(hybrid.Tag), + }; + } + + private static string ToBase64Url(string standardBase64) + => standardBase64.Replace('+', '-').Replace('/', '_').TrimEnd('='); +} diff --git a/src/tests/Cocoar.Configuration.Providers.Tests/LocalStorage/OverlayPathResolverTests.cs b/src/tests/Cocoar.Configuration.Providers.Tests/LocalStorage/OverlayPathResolverTests.cs new file mode 100644 index 0000000..b659b8e --- /dev/null +++ b/src/tests/Cocoar.Configuration.Providers.Tests/LocalStorage/OverlayPathResolverTests.cs @@ -0,0 +1,56 @@ +using Cocoar.Configuration.Providers; +using Xunit; + +namespace Cocoar.Configuration.Providers.Tests.LocalStorage; + +public class OverlayPathResolverTests +{ + [Fact] + [Trait("Type", "Unit")] + public void ResolveKeyPath_SimpleMemberChain() + { + var path = OverlayPathResolver.ResolveKeyPath(x => x.Nested.Count); + Assert.Equal("Nested.Count", path); + } + + [Fact] + [Trait("Type", "Unit")] + public void ResolveKeyPath_TopLevelMember() + { + var path = OverlayPathResolver.ResolveKeyPath(x => x.Port); + Assert.Equal("Port", path); + } + + [Fact] + [Trait("Type", "Unit")] + public void ResolveKeyPath_HonorsJsonPropertyName() + { + var path = OverlayPathResolver.ResolveKeyPath(x => x.Renamed); + Assert.Equal("custom_name", path); + } + + [Fact] + [Trait("Type", "Unit")] + public void ResolveKeyPath_MethodCall_Throws() + { + Assert.Throws(() => + OverlayPathResolver.ResolveKeyPath(x => x.Host!.ToUpperInvariant())); + } + + [Fact] + [Trait("Type", "Unit")] + public void ResolveKeyPath_Indexer_Throws() + { + Assert.Throws(() => + OverlayPathResolver.ResolveKeyPath(x => x.Items[0])); + } + + [Fact] + [Trait("Type", "Unit")] + public void ResolveKeyPath_SecretMember_Throws() + { + var ex = Assert.Throws(() => + OverlayPathResolver.ResolveKeyPath(x => x.ApiKey)); + Assert.Contains("secret", ex.Message, StringComparison.OrdinalIgnoreCase); + } +} diff --git a/src/tests/Cocoar.Configuration.Providers.Tests/LocalStorage/OverlayTestSupport.cs b/src/tests/Cocoar.Configuration.Providers.Tests/LocalStorage/OverlayTestSupport.cs new file mode 100644 index 0000000..4787030 --- /dev/null +++ b/src/tests/Cocoar.Configuration.Providers.Tests/LocalStorage/OverlayTestSupport.cs @@ -0,0 +1,54 @@ +using System.Text.Json.Serialization; +using Cocoar.Configuration.Providers; +using Cocoar.Configuration.Secrets.SecretTypes; + +namespace Cocoar.Configuration.Providers.Tests.LocalStorage; + +public sealed class SmtpSettings +{ + public string? Host { get; set; } + public int Port { get; set; } + public bool UseSsl { get; set; } + public NestedSettings Nested { get; set; } = new(); +} + +public sealed class NestedSettings +{ + public string? Level { get; set; } + public int Count { get; set; } +} + +public sealed class AttributedSettings +{ + [JsonPropertyName("custom_name")] + public string? Renamed { get; set; } +} + +public sealed class SecretSettings +{ + public Secret? ApiKey { get; set; } + public string? Plain { get; set; } +} + +public sealed class IndexableSettings +{ + public List Items { get; set; } = new(); +} + +/// In-memory storage backend for deterministic overlay tests (no file I/O). +public sealed class InMemoryBackend : IStorageBackend +{ + private byte[]? _data; + + public InMemoryBackend(byte[]? seed = null) => _data = seed; + + public byte[]? Current => _data; + + public Task ReadAsync(string key, CancellationToken ct = default) => Task.FromResult(_data); + + public Task WriteAsync(string key, byte[] data, CancellationToken ct = default) + { + _data = data; + return Task.CompletedTask; + } +} diff --git a/src/tests/Cocoar.Configuration.Providers.Tests/LocalStorage/SparseOverlayMutatorTests.cs b/src/tests/Cocoar.Configuration.Providers.Tests/LocalStorage/SparseOverlayMutatorTests.cs new file mode 100644 index 0000000..c92aadf --- /dev/null +++ b/src/tests/Cocoar.Configuration.Providers.Tests/LocalStorage/SparseOverlayMutatorTests.cs @@ -0,0 +1,161 @@ +using System.Text; +using System.Text.Json; +using System.Text.Json.Nodes; +using Cocoar.Configuration.Providers; +using Cocoar.Json.Mutable; +using Xunit; + +namespace Cocoar.Configuration.Providers.Tests.LocalStorage; + +public class SparseOverlayMutatorTests +{ + private static byte[] Empty => "{}"u8.ToArray(); + + private static MutableJsonObject Base(string json) + => (MutableJsonObject)MutableJsonDocument.Parse(Encoding.UTF8.GetBytes(json)); + + private static JsonElement Parse(byte[] bytes) + { + using var doc = JsonDocument.Parse(bytes); + return doc.RootElement.Clone(); + } + + [Fact] + [Trait("Type", "Unit")] + public void Set_CreatesNestedSparsePath_OnlyTouchedLeaf() + { + var result = SparseOverlayMutator.Set(Empty, "Nested.Count", JsonValue.Create(7), baseDom: null); + + var root = Parse(result); + Assert.Equal(7, root.GetProperty("Nested").GetProperty("Count").GetInt32()); + // Only the touched leaf is present — no sibling defaults leaked in. + Assert.Single(EnumerateNames(root)); + Assert.Single(EnumerateNames(root.GetProperty("Nested"))); + } + + [Fact] + [Trait("Type", "Unit")] + public void Set_AlignsCasingToBase_CamelCaseBase() + { + var baseDom = Base("{\"smtp\":{\"port\":25}}"); + + var result = SparseOverlayMutator.Set(Empty, "Smtp.Port", JsonValue.Create(587), baseDom); + + var root = Parse(result); + // The override must land on the base's exact key casing, not create PascalCase siblings. + Assert.True(root.TryGetProperty("smtp", out var smtp)); + Assert.Equal(587, smtp.GetProperty("port").GetInt32()); + Assert.False(root.TryGetProperty("Smtp", out _)); + } + + [Fact] + [Trait("Type", "Unit")] + public void Set_DescendsBaseByBaseKey_NotDriftedOverlayKey() + { + // The overlay already holds an intermediate key that is byte-different from the base's key + // (case-insensitively equal). The base descent must still follow the BASE's casing so the new + // leaf aligns to the base layer's deeper key, independent of the drifted overlay key's casing. + var seeded = Encoding.UTF8.GetBytes("{\"Smtp\":{\"host\":\"h\"}}"); + var baseDom = Base("{\"smtp\":{\"port\":25}}"); + + var result = SparseOverlayMutator.Set(seeded, "Smtp.Port", JsonValue.Create(587), baseDom); + + var root = Parse(result); + // Leaf aligned to the base's "port" casing — not the resolver default "Port". + Assert.Equal(587, root.GetProperty("Smtp").GetProperty("port").GetInt32()); + Assert.False(root.GetProperty("Smtp").TryGetProperty("Port", out _)); + } + + [Fact] + [Trait("Type", "Unit")] + public void Set_ExplicitNull_WritesJsonNull() + { + var result = SparseOverlayMutator.Set(Empty, "Host", valueNode: null, baseDom: null); + + var root = Parse(result); + Assert.Equal(JsonValueKind.Null, root.GetProperty("Host").ValueKind); + } + + [Fact] + [Trait("Type", "Unit")] + public void Set_DefaultValuedLeaf_IsPersisted() + { + var result = SparseOverlayMutator.Set(Empty, "Port", JsonValue.Create(0), baseDom: null); + + var root = Parse(result); + Assert.True(root.TryGetProperty("Port", out var port)); + Assert.Equal(0, port.GetInt32()); + } + + [Fact] + [Trait("Type", "Unit")] + public void Set_SecondLeafUnderSameParent_KeepsBoth() + { + var first = SparseOverlayMutator.Set(Empty, "Smtp.Port", JsonValue.Create(587), baseDom: null); + var second = SparseOverlayMutator.Set(first, "Smtp.Host", JsonValue.Create("h"), baseDom: null); + + var root = Parse(second); + Assert.Equal(587, root.GetProperty("Smtp").GetProperty("Port").GetInt32()); + Assert.Equal("h", root.GetProperty("Smtp").GetProperty("Host").GetString()); + } + + [Fact] + [Trait("Type", "Unit")] + public void Remove_PrunesEmptyAncestors() + { + var seeded = Encoding.UTF8.GetBytes("{\"Smtp\":{\"Port\":587}}"); + + var (bytes, removed) = SparseOverlayMutator.Remove(seeded, "Smtp.Port"); + + Assert.True(removed); + Assert.Equal("{}", Encoding.UTF8.GetString(bytes)); + } + + [Fact] + [Trait("Type", "Unit")] + public void Remove_KeepsSiblings() + { + var seeded = Encoding.UTF8.GetBytes("{\"Smtp\":{\"Port\":587,\"Host\":\"x\"}}"); + + var (bytes, removed) = SparseOverlayMutator.Remove(seeded, "Smtp.Port"); + + Assert.True(removed); + var root = Parse(bytes); + Assert.False(root.GetProperty("Smtp").TryGetProperty("Port", out _)); + Assert.Equal("x", root.GetProperty("Smtp").GetProperty("Host").GetString()); + } + + [Fact] + [Trait("Type", "Unit")] + public void Remove_AbsentKey_IsNoOp() + { + var seeded = Encoding.UTF8.GetBytes("{\"Smtp\":{\"Port\":587}}"); + + var (bytes, removed) = SparseOverlayMutator.Remove(seeded, "Other.Key"); + + Assert.False(removed); + Assert.Same(seeded, bytes); + } + + [Fact] + [Trait("Type", "Unit")] + public void Remove_CaseInsensitiveMatch_Removes() + { + var seeded = Encoding.UTF8.GetBytes("{\"smtp\":{\"port\":587}}"); + + var (bytes, removed) = SparseOverlayMutator.Remove(seeded, "Smtp.Port"); + + Assert.True(removed); + Assert.Equal("{}", Encoding.UTF8.GetString(bytes)); + } + + private static List EnumerateNames(JsonElement obj) + { + var names = new List(); + foreach (var p in obj.EnumerateObject()) + { + names.Add(p.Name); + } + return names; + } +} diff --git a/src/tests/Cocoar.Configuration.Providers.Tests/SecretEncryptionKeyProviderTests.cs b/src/tests/Cocoar.Configuration.Providers.Tests/SecretEncryptionKeyProviderTests.cs new file mode 100644 index 0000000..8c4246a --- /dev/null +++ b/src/tests/Cocoar.Configuration.Providers.Tests/SecretEncryptionKeyProviderTests.cs @@ -0,0 +1,200 @@ +using System.Security.Cryptography; +using System.Text.Json; +using Cocoar.Configuration.Core; +using Cocoar.Configuration.DI; +using Cocoar.Configuration.LocalStorage; +using Cocoar.Configuration.Providers; +using Cocoar.Configuration.Providers.Tests.LocalStorage; +using Cocoar.Configuration.Providers.Tests.TestUtilities; +using Cocoar.Configuration.Rules; +using Cocoar.Configuration.Secrets; +using Cocoar.Configuration.Secrets.SecretTypes; +using Cocoar.Configuration.X509Encryption; +using Microsoft.Extensions.DependencyInjection; +using Xunit; + +namespace Cocoar.Configuration.Providers.Tests; + +/// +/// Verifies the secrets encryption-key publishing accessor: it exposes the configured single kid's +/// current PUBLIC key (SPKI, base64url-no-padding), and a producer holding only that public key can +/// build an envelope the server decrypts — without ever touching the private cert. +/// +[Trait("Type", "Unit")] +public sealed class SecretEncryptionKeyProviderTests +{ + public sealed class VaultConfig + { + public Secret? ApiKey { get; set; } + } + + [Fact] + public void GetCurrentKeys_SingleKid_PublishesOnePublicKey() + { + const string kid = "publish-kid"; + RunWithCert(kid, provider => + { + var keyProvider = provider.GetRequiredService(); + + var key = Assert.Single(keyProvider.GetCurrentKeys()); + + Assert.Equal(kid, key.Kid); + Assert.Equal(SecretAlgorithms.Hybrid, key.Alg); + Assert.Equal(SecretAlgorithms.KeyWrap, key.Walg); + Assert.Equal(SecretAlgorithms.DataEncryption, key.Enc); + Assert.Equal("spki", key.Format); + Assert.Equal("base64url", key.Encoding); + Assert.False(string.IsNullOrEmpty(key.PublicKey)); + + // base64url WITHOUT padding — never standard-base64 characters. + Assert.DoesNotContain('+', key.PublicKey); + Assert.DoesNotContain('/', key.PublicKey); + Assert.DoesNotContain('=', key.PublicKey); + + // The published bytes are a valid SubjectPublicKeyInfo. + using var rsa = RSA.Create(); + rsa.ImportSubjectPublicKeyInfo(FromBase64Url(key.PublicKey), out _); + }); + } + + [Fact] + public void GetCurrentKey_ReturnsKeyForConfiguredKid_NullOtherwise() + { + const string kid = "lookup-kid"; + RunWithCert(kid, provider => + { + var keyProvider = provider.GetRequiredService(); + + Assert.NotNull(keyProvider.GetCurrentKey(kid)); + Assert.Null(keyProvider.GetCurrentKey("not-configured")); + Assert.Null(keyProvider.GetCurrentKey("")); + }); + } + + [Fact] + public void NoSecretsConfigured_ProviderNotRegistered() + { + var services = new ServiceCollection(); + services.AddCocoarConfiguration(c => c.UseConfiguration(_ => Array.Empty())); + using var provider = services.BuildServiceProvider(); + + Assert.Null(provider.GetService()); + } + + [Fact] + public async Task PublishedPublicKey_EncryptsValue_ServerDecryptsToOriginal() + { + const string kid = "roundtrip-kid"; + const string secretValue = "round-trip-secret"; + + var pfxPath = NewPfxPath(); + using var cert = X509CertificateGenerator.GenerateAndSavePfx(pfxPath, password: null, "CN=Cocoar Test", overwrite: true); + try + { + var backend = new InMemoryBackend(); + var services = new ServiceCollection(); + services.AddCocoarConfiguration(c => c + .UseConfiguration(rules => new ConfigRule[] + { + rules.For().FromStaticJson("{}"), + rules.For().FromLocalStorage(backend), + }) + .UseSecretsSetup(secrets => secrets.UseCertificateFromFile(pfxPath).WithKeyId(kid))); + + using var provider = services.BuildServiceProvider(); + + // 1. Fetch ONLY the published public key — the producer never sees the private cert. + var published = provider.GetRequiredService().GetCurrentKey(kid); + Assert.NotNull(published); + + // 2. Encrypt client-side with that public key alone (what a browser does). + using var rsaPublic = RSA.Create(); + rsaPublic.ImportSubjectPublicKeyInfo(FromBase64Url(published!.PublicKey), out _); + var envelope = EncryptWithPublicKey(rsaPublic, kid, secretValue); + + // 3. Server stores the envelope and decrypts with the matching private key. + var storage = provider.GetRequiredService>(); + var manager = provider.GetRequiredService(); + await storage.SetSecretAsync(x => x.ApiKey!, envelope); + + await ActiveWaitHelpers.WaitUntilAsync( + () => manager.GetConfig()?.ApiKey is not null, + TimeSpan.FromSeconds(5), description: "published-key envelope applied"); + + using var lease = manager.GetConfig()!.ApiKey!.Open(); + Assert.Equal(secretValue, lease.Value); + } + finally + { + if (System.IO.File.Exists(pfxPath)) System.IO.File.Delete(pfxPath); + } + } + + private static void RunWithCert(string kid, Action test) + { + var pfxPath = NewPfxPath(); + using var cert = X509CertificateGenerator.GenerateAndSavePfx(pfxPath, password: null, "CN=Cocoar Test", overwrite: true); + try + { + var services = new ServiceCollection(); + services.AddCocoarConfiguration(c => c + .UseConfiguration(_ => Array.Empty()) + .UseSecretsSetup(secrets => secrets.UseCertificateFromFile(pfxPath).WithKeyId(kid))); + + using var provider = services.BuildServiceProvider(); + test(provider); + } + finally + { + if (System.IO.File.Exists(pfxPath)) System.IO.File.Delete(pfxPath); + } + } + + private static string NewPfxPath() + => Path.Combine(Path.GetTempPath(), "cocoar_pubkey_" + Guid.NewGuid().ToString("N") + ".pfx"); + + private static SecretEnvelope EncryptWithPublicKey(RSA rsaPublic, string kid, string value) + { + var plaintext = JsonSerializer.SerializeToUtf8Bytes(value); + + Span dek = stackalloc byte[32]; + RandomNumberGenerator.Fill(dek); + try + { + var iv = new byte[12]; + RandomNumberGenerator.Fill(iv); + var ct = new byte[plaintext.Length]; + var tag = new byte[16]; + + using (var aes = new AesGcm(dek, tag.Length)) + { + aes.Encrypt(iv, plaintext, ct, tag, associatedData: null); + } + + var wk = rsaPublic.Encrypt(dek.ToArray(), RSAEncryptionPadding.OaepSHA256); + + return new SecretEnvelope + { + Kid = kid, + Wk = ToBase64Url(wk), + Iv = ToBase64Url(iv), + Ct = ToBase64Url(ct), + Tag = ToBase64Url(tag), + }; + } + finally + { + CryptographicOperations.ZeroMemory(dek); + } + } + + private static string ToBase64Url(byte[] bytes) + => Convert.ToBase64String(bytes).Replace('+', '-').Replace('/', '_').TrimEnd('='); + + private static byte[] FromBase64Url(string value) + { + var b64 = value.Replace('-', '+').Replace('_', '/'); + b64 += (b64.Length % 4) switch { 2 => "==", 3 => "=", _ => string.Empty }; + return Convert.FromBase64String(b64); + } +} diff --git a/website/.vitepress/config.ts b/website/.vitepress/config.ts index e4703ef..03cfe83 100644 --- a/website/.vitepress/config.ts +++ b/website/.vitepress/config.ts @@ -66,6 +66,7 @@ export default defineConfig({ { text: 'HTTP Polling', link: '/guide/providers/http-polling' }, { text: 'Microsoft IConfiguration', link: '/guide/providers/microsoft-adapter' }, { text: 'Static & Observable', link: '/guide/providers/static-observable' }, + { text: 'LocalStorage', link: '/guide/providers/localstorage' }, { text: 'Custom Providers ', link: '/guide/providers/custom' }, ], }, diff --git a/website/changelog.md b/website/changelog.md index 6cf1e5e..0fe757b 100644 --- a/website/changelog.md +++ b/website/changelog.md @@ -2,6 +2,19 @@ ## [Unreleased] +### Added + +**LocalStorage — writable override layer** +- A writable, application-controlled layer for *overridable defaults*: the normal sources supply defaults; the app overrides individual values at runtime. +- `ILocalStorage` (type-safe facade) and `ILocalStorageOverlay` (raw key-path surface) in `Cocoar.Configuration.Abstractions` +- **Sparse writes** — `SetAsync(x => x.Smtp.Port, value)` persists only the touched leaf; unset keys keep inheriting from lower layers +- `ResetAsync(...)` removes an override (falls back to the inherited default); an explicit `null` override is distinct from reset +- `DescribeAsync()` returns per-key provenance (`OverrideEntry`: base, effective, `IsOverridden`) for management UIs +- `.FromLocalStorage()` rule extension; file-based backend by default, pluggable `IStorageBackend` +- `ILocalStorage` / `ILocalStorageOverlay` are DI-injectable (single shared singleton) — write your own endpoints with your own validation/normalization/logging +- Secret-typed members cannot be overridden via LocalStorage (throws `NotSupportedException`) +- `IProviderServiceRegistration` gained resolve-time factory registration support + ### Changed **Secrets — robust enum & casing handling** diff --git a/website/guide/providers/localstorage.md b/website/guide/providers/localstorage.md new file mode 100644 index 0000000..0fd506f --- /dev/null +++ b/website/guide/providers/localstorage.md @@ -0,0 +1,182 @@ +# LocalStorage Provider + +LocalStorage is a **writable, application-controlled override layer**. Every other provider is an external source you read from — LocalStorage is the one layer your application can write to at runtime. + +Its purpose is **overridable defaults**: the normal sources (files, environment, …) supply defaults, and the application overrides *individual* values at runtime — from an admin UI, an API, or a background job — while everything it doesn't touch keeps inheriting from the lower layers. + +```csharp +rules => +[ + rules.For().FromFile("appsettings.json"), // defaults + rules.For().FromLocalStorage(), // app-controlled overrides (placed last → wins) +] +``` + +Position matters: place the LocalStorage rule **after** the rules whose values it should override. + +## Sparse overrides + +LocalStorage persists a **sparse** JSON object — only the leaves you explicitly set. Everything else is physically absent and therefore inherits from the lower layers through the normal byte-level merge. + +Given the defaults `{ "Host": "smtp.default.com", "Port": 25, "UseSsl": false }`, after: + +```csharp +await storage.SetAsync(x => x.Port, 587); +await storage.SetAsync(x => x.UseSsl, true); +``` + +the persisted overlay is just: + +```json +{ "Port": 587, "UseSsl": true } +``` + +and the effective configuration is `Host=smtp.default.com` (inherited), `Port=587`, `UseSsl=true`. + +This is the key difference from a "save the whole object" store: setting one value never freezes the others. If a default changes in the file later, every key you didn't override picks it up. + +## Reading and writing + +Inject `ILocalStorage` (registered as a **Singleton**, thread-safe) to override values at runtime: + +```csharp +public class SettingsController(ILocalStorage storage) +{ + // Override a single value — only this leaf is persisted; a recompute fires + // and IReactiveConfig emits the new effective value. + public Task SetPort(int port) => storage.SetAsync(x => x.Port, port); + + // Reset one override — the value falls back to the inherited default. + public Task ResetPort() => storage.ResetAsync(x => x.Port); + + // Clear every override this layer holds. + public Task ResetAll() => storage.ClearAsync(); +} +``` + +The selector must be a **simple member-access chain** (`x => x.Smtp.Port`). Indexers, method calls, and casts throw `NotSupportedException` — use the raw [overlay surface](#raw-overlay-surface) for dynamic paths. + +::: tip Writes are reactive +A write persists to storage, signals the provider, and triggers a (debounced) recompute. Subscribers of `IReactiveConfig` receive the new merged value automatically. +::: + +### Reset vs. explicit null + +These are deliberately different operations: + +| Operation | Overlay result | Effective value | +|---|---|---| +| `ResetAsync(x => x.Host)` | key removed | **inherits** the lower-layer value | +| `SetAsync(x => x.Host, null)` | `{ "Host": null }` | **overridden to `null`** (clobbers the base) | + +### Overriding to a default-looking value + +Because only touched keys are persisted, overriding to a value that happens to equal the C# default still counts as an override: + +```csharp +await storage.SetAsync(x => x.Port, 0); // persists {"Port":0} → effective Port is 0, even if the base was 25 +``` + +This is the headline correctness win: "an admin chose `0`" is distinct from "nobody set it." + +### Reading the overlay + +```csharp +SmtpSettings? overrides = await storage.ReadAsync(); // sparse partial T (unset members = C# defaults), null if empty +JsonNode? raw = await storage.Overlay.ReadOverlayAsync(); // the raw stored fragment, null if empty +``` + +`ReadAsync` returns only what the overlay holds — **not** the merged result. For the effective value use `IReactiveConfig.CurrentValue` or `IConfigurationAccessor.GetConfig()`. + +## Provenance for a management UI + +`DescribeAsync()` returns, per key, the base value, the effective value, and whether it is currently overridden — everything a "default vs. override, with reset" UI needs: + +```csharp +foreach (var entry in await storage.DescribeAsync()) +{ + // entry.KeyPath, entry.BaseValue, entry.EffectiveValue, entry.IsOverridden +} +``` + +| KeyPath | BaseValue | EffectiveValue | IsOverridden | +|---|---|---|---| +| `Host` | `"smtp.default.com"` | `"smtp.default.com"` | `false` | +| `Port` | `25` | `587` | `true` | +| `UseSsl` | `false` | `true` | `true` | + +`BaseValue` is the value computed from the layers **below** this overlay — i.e. what the key would be if the override were removed. + +## Raw overlay surface + +For dynamic or non-expressible paths, use `ILocalStorageOverlay` (also resolvable directly from DI, or via `storage.Overlay`). Key paths are dotted; their segments must match the persisted JSON property names: + +```csharp +await storage.Overlay.SetAsync("Smtp.Port", JsonValue.Create(587)); +await storage.Overlay.ResetAsync("Smtp.Port"); +``` + +The typed facade aligns key casing to the lower layers for you; with the raw surface that responsibility is yours. Do **not** use the raw surface for secret paths. + +## Arrays and secrets + +- **Arrays are replaced wholesale.** `SetAsync(x => x.Hosts, list)` overrides the entire array — there is no element-level merge. Per-element selectors (`x => x.Hosts[2]`) are rejected. +- **Secrets are not overridable.** Members typed as `Secret` / `ISecret` throw `NotSupportedException` on the typed facade — an overlay write would replace the encrypted secret with a mask. Manage secrets via the Secrets CLI/provider. + +## Writing your own endpoints + +There is no built-in REST surface — and that's deliberate. Writes are where *your* rules live (validation, normalization, authorization, audit logging, request shape), so the library gives you the injectable primitive and you own the endpoint. Inject `ILocalStorage` (or `ILocalStorageOverlay`) anywhere and do your work *before* writing: + +```csharp +app.MapPut("/admin/smtp/port", async ( + int port, + ILocalStorage storage, + ILogger log) => +{ + if (port is < 1 or > 65535) // validate + return Results.BadRequest("Port must be 1–65535."); + + log.LogInformation("Admin override SMTP.Port = {Port}", port); // audit + await storage.SetAsync(x => x.Port, port); // then persist (sparse) → recompute → reactive emit + return Results.NoContent(); +}) +.RequireAuthorization("AdminPolicy"); + +// Expose the provenance view for a management UI: +app.MapGet("/admin/smtp", (ILocalStorage storage, CancellationToken ct) => + storage.DescribeAsync(ct)); +``` + +For a generic admin UI that sets arbitrary keys, inject the raw `ILocalStorageOverlay` and pass the dotted key path and a `JsonNode` yourself (your code is responsible for validating the path and value). + +Both `ILocalStorage` and `ILocalStorageOverlay` are registered by `AddCocoarConfiguration` as the **same** singleton instance, so either can be injected into controllers or minimal-API handlers. + +## Storage backends + +By default, overrides are persisted as a JSON file under `{AppContext.BaseDirectory}/.cocoar/localStorage/`, written atomically (temp-file-then-rename). Plug in your own store by implementing `IStorageBackend`: + +```csharp +rules.For().FromLocalStorage(new MyDatabaseBackend()); +``` + +A config-aware overload receives the current configuration and backend, for backends whose connection depends on earlier rules (e.g. a connection string): + +```csharp +rules.For().FromLocalStorage((accessor, current) => + current ?? new DbBackend(accessor.GetConfig()!.ConnectionString)); +``` + +`ReadAsync` returns empty `{}` when nothing is stored (consistent with the [provider contract](/guide/providers/overview#the-provider-contract)), so an unwritten overlay is an invisible layer. + +## How it works + +``` +ILocalStorage.SetAsync(x => x.Port, 587) + → resolve "Port" to a dotted key path + align casing to the lower layers + → atomically read-merge-write the sparse overlay leaf to the backend + → signal the provider's change observable + → engine recompute (debounced) merges layers byte-for-byte + → IReactiveConfig emits the new effective value +``` + +The read/merge path is identical to every other provider — LocalStorage only adds the write path. See the runnable [LocalStorageOverride example](https://github.com/) for an end-to-end walkthrough. diff --git a/website/guide/providers/overview.md b/website/guide/providers/overview.md index fd7d767..2c8e055 100644 --- a/website/guide/providers/overview.md +++ b/website/guide/providers/overview.md @@ -29,6 +29,7 @@ On failure, providers return an empty JSON object `{}` — never null. This mean | [Command Line](/guide/providers/command-line) | `.FromCommandLine("--prefix")` | No | Core | | [Static JSON](/guide/providers/static-observable#static-json) | `.FromStaticJson("{...}")` | No | Core | | [Observable](/guide/providers/static-observable#observable) | `.FromObservable(obs)` | Yes | Core | +| [LocalStorage](/guide/providers/localstorage) | `.FromLocalStorage()` | Yes (on write) | Core | | [HTTP](/guide/providers/http-polling) | `.FromHttp(url)` | Polling / SSE / one-time | Http | | [Microsoft IConfiguration](/guide/providers/microsoft-adapter) | `.FromIConfiguration(config)` | IConfiguration reload token | MicrosoftAdapter | From 6479f6ee11d79496b97a8cda28c4c5a03f8bfeea Mon Sep 17 00:00:00 2001 From: Bernhard Windisch Date: Sat, 30 May 2026 21:19:57 +0200 Subject: [PATCH 02/18] feat: multi-tenant configuration (ADR-005) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per-tenant configuration on a shared global base. One ConfigManager owns a global TenantPipeline plus a per-tenant registry; author one flat rule list and mark per-tenant rules with .TenantScoped() — the tenant id flows via IConfigurationAccessor.Tenant. - Effective value per tenant = [global] ++ [tenant-scoped] through the same recompute/merge pipeline (flatten, not blob-overlay) - ITenantConfigurationAccessor lifecycle: InitializeTenantAsync / EnsureTenantInitializedAsync / IsTenantInitialized / RemoveTenantAsync (build-once under concurrency, race-hardened) - Per-tenant access: GetConfigForTenant / GetReactiveConfigForTenant / GetFeatureFlagsForTenant / GetEntitlementsForTenant / GetLocalStorageForTenant - Tenant-only types excluded from the global DI plan (no captive dependency); per-tenant flags/entitlements need no source-generator change - Automatic fan-out via per-tenant subscriptions; per-tenant eventual consistency - ASP.NET Core: scoped ITenantReactiveConfig + ITenantContext; MapTenantFeatureFlagEndpoints / MapTenantEntitlementEndpoints Reactive factory loosened to (IConfigurationAccessor + MasterBackplane); ConfigManager state/engine/accessor extracted into the reusable TenantPipeline bundle. --- .../adr/ADR-005-multi-tenant-configuration.md | 195 ++++++++++++ .../Core/IConfigurationAccessor.cs | 8 + .../Core/ITenantConfigurationAccessor.cs | 54 ++++ .../TenantFlagEvaluationExtensions.cs | 114 +++++++ .../ServiceRegistrationPlanner.cs | 17 +- src/Cocoar.Configuration.slnx | 1 + .../Core/ConfigManager.cs | 278 ++++++++++++++---- .../Core/ConfigurationAccessor.cs | 7 +- .../Core/ILocalStorageHost.cs | 18 ++ .../Core/TenantPipeline.cs | 167 +++++++++++ .../Flags/ConfigManagerFlagsExtensions.cs | 69 ++++- .../Flags/Internal/EntitlementsSetupData.cs | 4 + .../Flags/Internal/FlagsSetupData.cs | 4 + .../Fluent/ProviderRuleBuilder.cs | 3 +- .../Fluent/RuleBuilderBase.cs | 26 ++ .../LocalStorageAdapter.cs | 16 +- .../LocalStorageRulesExtensions.cs | 10 +- .../TenantLocalStorageExtensions.cs | 53 ++++ .../Reactive/ReactiveConfigurationFactory.cs | 16 +- .../Reactive/ReactiveTupleConfig.cs | 22 +- .../Rules/ConfigRuleOptions.cs | 3 +- src/Cocoar.Configuration/Rules/RuleManager.cs | 19 +- .../TenantFlagEvaluationEndpointTests.cs | 77 +++++ .../MultiTenant/TenantScopedRuleTests.cs | 44 +++ ...oar.Configuration.MultiTenant.Tests.csproj | 34 +++ .../TenantConfigTests.cs | 159 ++++++++++ .../TenantDiExclusionTests.cs | 47 +++ .../TenantFanOutTests.cs | 54 ++++ .../TenantFlagsTests.cs | 100 +++++++ .../TenantLifecycleTests.cs | 127 ++++++++ .../TenantLocalStorageTests.cs | 76 +++++ .../TenantReactiveTests.cs | 81 +++++ .../TenantSecretsTests.cs | 129 ++++++++ .../TenantWait.cs | 28 ++ website/.vitepress/config.ts | 6 + website/guide/multi-tenancy/overview.md | 112 +++++++ 36 files changed, 2084 insertions(+), 94 deletions(-) create mode 100644 docs/adr/ADR-005-multi-tenant-configuration.md create mode 100644 src/Cocoar.Configuration.Abstractions/Core/ITenantConfigurationAccessor.cs create mode 100644 src/Cocoar.Configuration.AspNetCore/TenantFlagEvaluationExtensions.cs create mode 100644 src/Cocoar.Configuration/Core/ILocalStorageHost.cs create mode 100644 src/Cocoar.Configuration/Core/TenantPipeline.cs create mode 100644 src/Cocoar.Configuration/Providers/LocalStorageProvider/TenantLocalStorageExtensions.cs create mode 100644 src/tests/Cocoar.Configuration.AspNetCore.Tests/TenantFlagEvaluationEndpointTests.cs create mode 100644 src/tests/Cocoar.Configuration.Core.Tests/MultiTenant/TenantScopedRuleTests.cs create mode 100644 src/tests/Cocoar.Configuration.MultiTenant.Tests/Cocoar.Configuration.MultiTenant.Tests.csproj create mode 100644 src/tests/Cocoar.Configuration.MultiTenant.Tests/TenantConfigTests.cs create mode 100644 src/tests/Cocoar.Configuration.MultiTenant.Tests/TenantDiExclusionTests.cs create mode 100644 src/tests/Cocoar.Configuration.MultiTenant.Tests/TenantFanOutTests.cs create mode 100644 src/tests/Cocoar.Configuration.MultiTenant.Tests/TenantFlagsTests.cs create mode 100644 src/tests/Cocoar.Configuration.MultiTenant.Tests/TenantLifecycleTests.cs create mode 100644 src/tests/Cocoar.Configuration.MultiTenant.Tests/TenantLocalStorageTests.cs create mode 100644 src/tests/Cocoar.Configuration.MultiTenant.Tests/TenantReactiveTests.cs create mode 100644 src/tests/Cocoar.Configuration.MultiTenant.Tests/TenantSecretsTests.cs create mode 100644 src/tests/Cocoar.Configuration.MultiTenant.Tests/TenantWait.cs create mode 100644 website/guide/multi-tenancy/overview.md diff --git a/docs/adr/ADR-005-multi-tenant-configuration.md b/docs/adr/ADR-005-multi-tenant-configuration.md new file mode 100644 index 0000000..2d08daf --- /dev/null +++ b/docs/adr/ADR-005-multi-tenant-configuration.md @@ -0,0 +1,195 @@ +# ADR-005: Multi-Tenant Configuration + +**Status:** Accepted — implementation in progress on `feature/multitenant` (core config + lifecycle + automatic fan-out done; per-tenant reactive/flags/entitlements/localstorage/secrets, DI exclusion, AspNetCore, docs in progress) +**Date:** 2026-05-29 (updated 2026-05-30) +**Decision Makers:** Core Team +**Type:** Feature / Architecture +**Related:** ADR-001 (Capabilities), ADR-002 (Atomic Reactive Updates), ADR-004 (Aggregate Rules), PR #47 (LocalStorage sparse override overlay), Secrets encryption-key publishing + +--- + +## Context + +Multi-tenant applications need the **same configuration type to resolve to different values per tenant**. Three kinds of configuration coexist: + +1. **Global-only** — e.g. the master-table connection, the service's bind IP/port. One value, no tenant. +2. **Tenant-scoped** — valid per tenant. +3. **Global, but per-tenant overridable** (the common, preferred case) — a global value for everything, where a tenant overrides only the keys it sets and inherits the rest. + +### Constraints from the target environment + +- The host owns the tenant list (e.g. Marten with db-per-tenant); tenant connection strings live there, **not** in config. +- **Tenants are added and removed at runtime** — configuration cannot be static or precomputed for a fixed tenant set. +- Therefore **Cocoar.Configuration must be tenant-list-agnostic**: it is handed a tenant id and builds that tenant's configuration **on demand**, never enumerating or syncing a tenant registry. + +### Feasibility (engine-verified) + +The recompute/state/reactive engine was audited against this model. All pipeline building blocks (`ConfigurationEngine`, `ConfigurationState`, `MasterBackplane`, `ConfigSnapshot`, `ConfigJsonRepository`, `RecomputeScheduler`, `RuleManager`, `BackplaneReactiveConfig`) are **per-instance with no static singletons**, and the merge primitive (`MutableJsonMerge.Merge`, ordered last-write-wins — already used by `ConfigManager.BuildBaseJson`) already expresses the required layering. The model is feasible without rewriting the recompute, snapshot, or reactive cores. + +--- + +## Decision + +Introduce a **tenant dimension** carried by a per-tenant rule factory and a per-tenant pipeline bundle layered on the shared global state. + +### 1. Declaration — one flat rule list, per-rule `.TenantScoped()` + +> **Settled authoring API.** An earlier draft of this ADR used a `ForEachTenant((r, tenant) => …)` block. That, and a tiered builder, a `{tenant}` path token, and a top-level `(c, tenant)` lambda, were all rejected (see *Alternatives considered*). The final shape keeps **one flat rule list** and adds exactly one new primitive — a tenant marker on the rule — plus a tenant id on the accessor. + +Two pieces: + +- **`Tenant` on `IConfigurationAccessor`** — a default-interface member returning `null` in the global pipeline and the tenant id in a tenant pipeline. Tenant-varying rule factories interpolate it; `.TenantScoped()` keys off it. +- **`.TenantScoped()` on the rule builder** — marks a rule to run **only** when a tenant is present (skipped in the global, tenant-agnostic pipeline). Shorthand for `.When(a => !string.IsNullOrWhiteSpace(a.Tenant))`, AND-composed with any existing `When`. + +Existing providers compose unchanged — a tenant-varying source is just the existing `Func` factory with the id interpolated. No provider becomes "tenant-aware", and no new `Func` overloads are added. + +```csharp +services.AddCocoarConfiguration(c => c.UseConfiguration(rules => +[ + // Global-only (single state, injectable as today): + rules.For().FromStaticJson(masterDefaults), + + // Global base for a type that is ALSO tenant-overridable: + rules.For().FromStaticJson(smtpDefaults), + rules.For().FromLocalStorage(), // global app-override + + // Tenant-scoped overlays — same flat list, marked .TenantScoped(); the id flows via the accessor: + rules.For().FromFile(a => $"tenants/{a.Tenant}/smtp.json").TenantScoped(), + rules.For().FromLocalStorage((a, _) => BackendFor(a.Tenant)).TenantScoped(), // per-tenant backend +])); +``` + +A `.TenantScoped()` rule is registered once but contributes nothing in the global pipeline (its `When` is false there); in a tenant pipeline the same rule runs with `Tenant = id`. There is **no resolver in the definition** — the tenant id is supplied at query time (see §5). + +### 2. Per-tenant pipeline bundle on a shared global base + +The single global `ConfigManager` keeps exactly one global pipeline (unchanged, byte-identical). Each initialized tenant gets its own **pipeline bundle** (engine + state + backplane + reactive manager + rule managers), held in a `ConcurrentDictionary`. Tenant pipelines share the single, read-only `ExposureRegistry`. `ConfigSnapshot` stays keyed by `Type` only — the tenant dimension is the registry key, not a composite snapshot key. + +### 3. Rule composition — FLATTEN, not blob-overlay + +For a tenant-scoped type `T`, the effective layer stack is the **flattened rule list** `[global rules for T] ++ [tenant rules for T]`, run through the **same recompute pipeline** as a normal config. + +> We do **not** merge a pre-computed tenant-JSON blob onto a pre-computed global-JSON blob. The recompute pipeline is the single proven path that turns an ordered rule list into a value — including transforms (`Mount`/`Select`), required-rule rollback, and dependency ordering. Flattening reuses that one path; a "merge two blobs" step would fork it and break those transforms. + +The tenant segment is a **positioned segment** of the flattened list. Placing it last (the default) gives "tenant wins per key, else inherits global". Placing a global rule after it would let a global value override tenants (forced/compliance policy) — supported by construction, not a special case. + +> **v1 implementation: full-list-per-tenant (seed-from-global deferred).** Each tenant pipeline runs the **entire** flat rule list — global rules included — with its own rule managers and providers. This is correct and, crucially, gives **automatic fan-out** (§6): each tenant holds its own subscription to a live global base source, so a base change reaches the tenant with no cross-pipeline machinery. The cost is linear: N tenants re-run the base rules. The originally-planned **seed-from-global** optimization (read the global managers' last contribution lock-free and recompute only the tenant-scoped suffix) is **deferred** — it would save the re-run but trade away automatic fan-out for an explicit coordinator and a lock-ordering hazard against the global recompute semaphore. It remains a clean, isolated optimization to add behind the unchanged public API if tenant/rule counts ever make the re-run cost matter. + +### 4. Lifecycle — explicit, async at init, sync at read + +Mirrors `ConfigManager.CreateAsync` (build async, then serve sync), scoped per tenant: + +```csharp +await mgr.InitializeTenantAsync(tenantId); // build the tenant's pipeline (async), at tenant creation +await mgr.EnsureTenantInitializedAsync(tenantId); // idempotent warmup (e.g. request-start middleware) +await mgr.RemoveTenantAsync(tenantId); // dispose the tenant's bundle, at tenant removal +``` + +**Reads stay synchronous** — `InitializeTenantAsync` does the async work once; afterward the tenant snapshot is read synchronously, exactly like the global config. Global reads and existing single-tenant apps are unchanged. Async is confined to the dynamic-tenant materialization moment. + +### 5. Consumption — explicit `…ForTenant(id)`, never injection + +Tenant-scoped values are obtained by **passing the tenant id**, never by DI injection: + +```csharp +var smtp = mgr.GetConfigForTenant(tenantId); // sync +var live = mgr.GetReactiveConfigForTenant(tenantId); +var store = mgr.GetLocalStorageForTenant(tenantId); // per-tenant write facade +var flags = mgr.GetFlagsForTenant(tenantId); +var ents = mgr.GetEntitlementsForTenant(tenantId); +``` + +**Tenant-scoped types/flags are NOT DI-injectable.** Injecting one into a long-lived (Singleton) consumer would be a captive-dependency bug — it would freeze one tenant forever, since the container cannot know the runtime tenant. The `ServiceRegistrationPlanner` therefore tags and **excludes** `ForEachTenant` types from the normal DI plan. Global types remain injectable as today. A consuming service injects the `ConfigManager` / `ITenantConfigurationAccessor` and calls `GetFlagsForTenant(currentTenant)` — explicitly tenant-aware, which is the correct shape for multi-tenant code. + +### 6. Fan-out — automatic via per-tenant subscriptions (v1) + +Each tenant snapshot layers on the global base, so a change to the **global** base must propagate to initialized tenants. + +**v1 (implemented):** because each tenant pipeline runs the full flat rule list (§3) with its **own** provider instances and **own** change subscriptions, a live global base source (file / observable / http) propagates to every initialized tenant **automatically** — each tenant's own base subscription fires its own debounced recompute, re-seeding from the new base and re-emitting on its own `IReactiveConfig` (content-gated by the engine's reference-equality change detection: a tenant that masks the changed key with its own override does not emit). No cross-pipeline coordinator runs, and there is no inline cross-pipeline read to deadlock. Consistency is per-tenant eventual, as decided below. This is verified by `TenantFanOutTests`. + +**Deferred (only relevant if seed-from-global lands):** if the base is ever shared rather than re-run per tenant (§3), tenants would no longer self-subscribe and an explicit **fan-out coordinator** becomes necessary — observing the global commit via `MasterBackplane.SnapshotStream` strictly **after** the global Publish and semaphore release (never inline), recomputing subscribed tenants and stale-marking idle ones. That coordinator is **not built** in v1 because the full-list-per-tenant model makes it unnecessary. + +### 7. Reach across the library + +The tenant dimension is unified by the factory + bundle: + +- **Feature Flags / Entitlements** become tenant-aware **without a source-generator change**: the generated flag class already reads an injected `IReactiveConfig`; tenant-awareness means constructing it with the **tenant's** `IReactiveConfig`. `GetFlagsForTenant(id)` is a per-`(tenant, TFlags)` factory/cache over the existing generated class. The context-aware evaluator and the REST endpoints (`MapFeatureFlagEndpoints`) gain a tenant dimension (e.g. a route segment). +- **LocalStorage** per tenant: reads fall out of the factory (`FromLocalStorage(BackendFor(tenant))`; file backend = a folder per tenant); writes go through a per-tenant `GetLocalStorageForTenant(id)` facade pointing at the tenant's backend. +- **Secrets** are already tenant-capable via folder mode (`kid` = tenant subfolder routes decryption); a tenant writes its encrypted envelope to its own backend, decrypted with its own cert. + +### 8. No-DI core preserved + +Tenant methods live on a **new** `ITenantConfigurationAccessor` that `ConfigManager` also implements; the existing `IConfigurationAccessor` stays byte-identical. The whole tenant lifecycle is explicit method calls — usable with zero DI container. + +### Settled product decisions + +- **Authoring:** one flat rule list + per-rule `.TenantScoped()` + `Tenant` on `IConfigurationAccessor` (§1); `ForEachTenant`/tiered-builder/`{tenant}`-token/top-level-`(c, tenant)` rejected (*Alternatives considered*). +- **Fan-out:** automatic via per-tenant subscriptions in v1 (§6); explicit coordinator deferred with seed-from-global. +- **Precedence:** two-layer `[global]++[tenant]`, tenant on top; a tenant's plan/license is a config **value/flag**, not a precedence tier. +- **Consistency:** per-tenant **eventual consistency** — a global change propagates tenant-by-tenant as each rebuild finishes (a deliberate relaxation of ADR-002's single-snapshot atomicity, which still holds *within* each tenant and within the global state). +- **Eviction:** explicit `RemoveTenantAsync` only — no idle-eviction or cap (the active-tenant set is host-bounded). + +--- + +## Engine impact + +| File / Area | Change | Kind | +|---|---|---| +| `Core/ConfigManager.cs` | Single-pipeline ownership → global bundle + `ConcurrentDictionary>>`; add `InitializeTenantAsync`/`EnsureTenantInitializedAsync`/`IsTenantInitialized`/`RemoveTenantAsync`/`GetConfigForTenant`/`GetReactiveConfigForTenant`; extend dispose | **Structural** — *done (b2)* | +| **NEW** `TenantPipeline` | Bundle of per-tenant engine/state/backplane/reactive/rules on the shared scope + frozen registry | **Structural / new** — *done (a/b2)* | +| ~~`TenantFanOutCoordinator`~~ | **Not built in v1** — full-list-per-tenant gives automatic fan-out (§6); coordinator only needed if seed-from-global lands | Deferred | +| `Core/ConfigurationEngine.cs` | seed-from-global recompute variant — **deferred** (§3); v1 re-runs the full list per tenant (correct, unoptimized) | Deferred | +| `Core/MasterBackplane.cs`, `ConfigurationState.cs`, `ConfigurationAccessor.cs` | Instantiated per tenant (no internal change); per-tenant accessor so the recompute-window fallback reads tenant JSON, not global | Additive | +| `Rules/ConfigRule.cs` (+ Fluent) | `ForEachTenant((r, tenant) => ConfigRule[])` builder surface; precedent is `AggregateConfigRule` | Additive | +| `DI/ServiceRegistrationPlanner.cs` | Tag/exclude `ForEachTenant` types from the normal DI plan | Additive | +| `DI/ServiceDescriptorEmitter.cs` | (Only if/when ambient injection is ever wanted — currently **out of scope**, see §5) | — | +| Abstractions | New `ITenantConfigurationAccessor`; existing `IConfigurationAccessor` unchanged | Additive | +| Flags/Entitlements | `GetFlagsForTenant`/`GetEntitlementsForTenant` factory/cache (no generator change); tenant dimension on evaluator + REST endpoints | Additive | + +**Net:** one structural change (ConfigManager ownership) + one new subsystem (fan-out coordinator). Everything else is additive reuse of existing per-instance machinery. No rewrite of the recompute/snapshot/reactive cores. + +--- + +## Consequences + +✅ Reuses the existing recompute/merge/reactive engine wholesale; the global pipeline and single-tenant apps are unchanged +✅ Reuses PR #47's overlay/merge primitives (`BuildBaseJson`, `MutableJsonMerge`) — that work is on-path for tenancy, not superseded +✅ Reads stay synchronous; async is confined to explicit tenant init +✅ No source-generator change for tenant-aware flags +✅ No-DI core preserved; tenant API additive on a new interface +✅ Captive-dependency class of bugs avoided by design (explicit `…ForTenant(id)` only) + +⚠️ Structural rework of `ConfigManager`'s "one state per manager" ownership model (mechanical but not an additive extension; `ConfigManager` is sealed) — **done**: extracted into `TenantPipeline`, global path byte-identical +⚠️ Per-tenant eventual consistency (vs. ADR-002 global atomicity) — observable for tuples spanning a global-only and a tenant-scoped type; **mixed-scope tuples are not supported in v1** +⚠️ Resource use scales linearly with initialized tenants × base rules (each tenant re-runs the global base); acceptable for a host-bounded active-tenant set, and the seed-from-global optimization can reclaim it later without an API change +⚠️ Each tenant holds its own subscription to live base sources — for an SSE/HTTP base that is N connections to the config server; document and revisit with seed-from-global if it bites at scale + +--- + +## Open questions (implementation-level) + +- **Fan-out throttle at scale:** with full-list-per-tenant, a global base change fans out as one independent debounced recompute per initialized tenant; whether a cross-pipeline throttle is needed depends on tenant/subscriber counts. Becomes pressing only if seed-from-global lands (a single coordinator then drives all tenants). +- **Mixed-scope tuples:** **decided — not supported in v1.** `IReactiveConfig<(Global, TenantScoped)>` / `IFeatureFlags<(Global, TenantScoped)>` would show transient skew; only same-scope tuples are supported. +- **Idle-read freshness contract:** moot in v1 — idle initialized tenants self-update via their own subscriptions, so a sync read is current. (Re-opens only with seed-from-global's stale-mark model.) + +--- + +## Alternatives considered (authoring API) + +All rejected in favor of *one flat rule list + per-rule `.TenantScoped()` + `Tenant` on the accessor* (§1): + +- **`ForEachTenant((r, tenant) => ConfigRule[])` block** (this ADR's first draft) — a second nested rule surface and a `Func` shape, duplicating the builder. Rejected: a whole parallel authoring path for what is one bit of metadata on a rule. The flat list with `.TenantScoped()` expresses the same precedence (position in the list) without a sub-builder. +- **Tiered builder** (`UseConfiguration` / `UseTenantConfiguration` / `WithNonNegotiable`, as in the POC) — makes precedence three named tiers. Rejected: "non-negotiable" is just a global rule placed after the tenant overlay in the flat list (§3), so the third tier is redundant; the two extra entry points add API surface without new capability. +- **`{tenant}` path token** (magic string interpolated by providers) — rejected: pushes tenancy into every provider's option parsing, exactly the "tenant-aware provider" coupling §1 avoids. The existing `Func` factory already interpolates `a.Tenant` with no provider change. +- **Top-level `(c, tenant)` lambda** on `AddCocoarConfiguration` — rejected: forces the tenant id into the *definition* phase, whereas the id is a *query-time* value (§5); it also can't express "global base + tenant overlay for the same type" in one list. + +--- + +## References + +- PR #47 — LocalStorage sparse override overlay (`ConfigManager.BuildBaseJson`, `MutableJsonMerge`) — the merge/overlay foundation reused here +- `src/Cocoar.Configuration/Core/ConfigurationEngine.cs` — recompute pipeline (per-instance semaphore + scheduler) +- `src/Cocoar.Configuration/Core/MasterBackplane.cs` — `SnapshotStream` (fan-out hook), per-instance publish/dispose +- `src/Cocoar.Configuration/Core/ConfigManager.cs` — current single-pipeline ownership to be extended +- ADR-002 — atomic reactive updates (relaxed to per-tenant eventual consistency here) +- ADR-004 — aggregate rules (`AggregateConfigRule` precedent for grouping sub-rules) diff --git a/src/Cocoar.Configuration.Abstractions/Core/IConfigurationAccessor.cs b/src/Cocoar.Configuration.Abstractions/Core/IConfigurationAccessor.cs index 7477228..647f351 100644 --- a/src/Cocoar.Configuration.Abstractions/Core/IConfigurationAccessor.cs +++ b/src/Cocoar.Configuration.Abstractions/Core/IConfigurationAccessor.cs @@ -69,4 +69,12 @@ public interface IConfigurationAccessor object GetRequiredConfig(Type type); JsonElement? GetConfigAsJson(Type type); + + /// + /// The tenant this accessor resolves configuration for, or null in the global (tenant-agnostic) pipeline. + /// Tenant-scoped rules (.TenantScoped()) are skipped when this is null/empty; tenant-varying rule + /// factories interpolate it (e.g. FromFile(a => $"db.{a.Tenant}.json")). + /// + /// Default interface member (returns null) so existing implementers are not broken. + string? Tenant => null; } diff --git a/src/Cocoar.Configuration.Abstractions/Core/ITenantConfigurationAccessor.cs b/src/Cocoar.Configuration.Abstractions/Core/ITenantConfigurationAccessor.cs new file mode 100644 index 0000000..1dd847f --- /dev/null +++ b/src/Cocoar.Configuration.Abstractions/Core/ITenantConfigurationAccessor.cs @@ -0,0 +1,54 @@ +using Cocoar.Configuration.Reactive; + +namespace Cocoar.Configuration.Core; + +/// +/// Tenant-scoped configuration lifecycle and access (ADR-005). Implemented by ConfigManager alongside +/// ; the existing global surface stays byte-identical. +/// +/// The active-tenant list is owned by the host (e.g. db-per-tenant). This surface materializes a single +/// tenant's configuration on demand and never enumerates or syncs a tenant registry. Each initialized +/// tenant is a pipeline bundle layered on the shared global base: the effective value for a tenant is +/// [global rules] ++ [tenant-scoped rules], tenant winning per key and inheriting the rest. +/// +/// +/// Async is confined to the explicit init moment (); afterwards reads +/// () are synchronous, exactly like the global config. +/// +/// +public interface ITenantConfigurationAccessor +{ + /// + /// Builds and initializes the tenant's pipeline (async). Idempotent: a tenant that is already initialized + /// (or whose initialization is in flight) is returned without rebuilding. Call at tenant creation. + /// + Task InitializeTenantAsync(string tenantId, CancellationToken cancellationToken = default); + + /// + /// Idempotent warmup — ensures the tenant is initialized, building it if needed. Identical semantics to + /// ; use at request start (middleware) where intent is "make sure it's ready". + /// + Task EnsureTenantInitializedAsync(string tenantId, CancellationToken cancellationToken = default); + + /// Returns true once the tenant's pipeline has been built and its initial snapshot published. + bool IsTenantInitialized(string tenantId); + + /// + /// Gets the tenant's current configuration value for (synchronous). + /// + /// The tenant has not been initialized. + T? GetConfigForTenant(string tenantId) where T : class; + + /// + /// Gets a reactive wrapper over the tenant's configuration value for . Emits the + /// current value on subscribe and on every subsequent change to THIS tenant's effective value (replay-1). + /// + /// The tenant has not been initialized. + IReactiveConfig GetReactiveConfigForTenant(string tenantId); + + /// + /// Disposes the tenant's pipeline bundle and forgets the tenant. Drains any in-flight recompute. Call at + /// tenant removal. A no-op for an unknown/already-removed tenant. + /// + Task RemoveTenantAsync(string tenantId, CancellationToken cancellationToken = default); +} diff --git a/src/Cocoar.Configuration.AspNetCore/TenantFlagEvaluationExtensions.cs b/src/Cocoar.Configuration.AspNetCore/TenantFlagEvaluationExtensions.cs new file mode 100644 index 0000000..7b96cf7 --- /dev/null +++ b/src/Cocoar.Configuration.AspNetCore/TenantFlagEvaluationExtensions.cs @@ -0,0 +1,114 @@ +using System.Reflection; +using Cocoar.Configuration.Core; +using Cocoar.Configuration.Flags; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.DependencyInjection; + +namespace Cocoar.Configuration.AspNetCore; + +/// +/// Tenant-aware REST evaluation endpoints (ADR-005 §7): the same auto-generated endpoints as +/// , dimensioned by a {tenant} route segment. The handler warms the +/// tenant up (EnsureTenantInitializedAsync) and evaluates the flag/entitlement against THAT tenant's +/// effective configuration via GetFeatureFlagsForTenant / GetEntitlementsForTenant. +/// +public static class TenantFlagEvaluationExtensions +{ + private static readonly Type FlagNoContextDef = typeof(FeatureFlag<>); + private static readonly Type EntitlementNoContextDef = typeof(Entitlement<>); + private static readonly MethodInfo GetFeatureFlagsForTenantMethod = + typeof(ConfigManagerFlagsExtensions).GetMethod(nameof(ConfigManagerFlagsExtensions.GetFeatureFlagsForTenant))!; + private static readonly MethodInfo GetEntitlementsForTenantMethod = + typeof(ConfigManagerFlagsExtensions).GetMethod(nameof(ConfigManagerFlagsExtensions.GetEntitlementsForTenant))!; + + /// + /// Maps per-tenant GET endpoints for all registered no-context feature flags. Route shape (default prefix): + /// GET /tenants/{tenant}/flags/{FlagClass}/{FlagName}{ "value": … } for that tenant. + /// + public static RouteGroupBuilder MapTenantFeatureFlagEndpoints( + this IEndpointRouteBuilder app, + string pathPrefix = "/tenants/{tenant}/flags") + { + var group = app.MapGroup(pathPrefix); + + var configManager = ((IApplicationBuilder)app).ApplicationServices.GetRequiredService(); + if (configManager.FlagsSetup is not { } capability) + { + return group; + } + + foreach (var registration in capability.Registrations) + { + MapTenantNoContextEndpoints(group, registration.Descriptor.Type, FlagNoContextDef, + GetFeatureFlagsForTenantMethod, "FeatureFlag evaluation failed"); + } + + return group; + } + + /// + /// Maps per-tenant GET endpoints for all registered no-context entitlements. Route shape (default prefix): + /// GET /tenants/{tenant}/entitlements/{EntitlementClass}/{Name}{ "value": … } for that tenant. + /// + public static RouteGroupBuilder MapTenantEntitlementEndpoints( + this IEndpointRouteBuilder app, + string pathPrefix = "/tenants/{tenant}/entitlements") + { + var group = app.MapGroup(pathPrefix); + + var configManager = ((IApplicationBuilder)app).ApplicationServices.GetRequiredService(); + if (configManager.EntitlementsSetup is not { } capability) + { + return group; + } + + foreach (var registration in capability.Registrations) + { + MapTenantNoContextEndpoints(group, registration.Descriptor.Type, EntitlementNoContextDef, + GetEntitlementsForTenantMethod, "Entitlement evaluation failed"); + } + + return group; + } + + private static void MapTenantNoContextEndpoints( + RouteGroupBuilder group, + Type classType, + Type noContextDelegateDef, + MethodInfo forTenantMethod, + string failureTitle) + { + var className = classType.Name; + + foreach (var prop in classType.GetProperties(BindingFlags.Public | BindingFlags.Instance)) + { + if (!prop.PropertyType.IsGenericType) continue; + if (prop.PropertyType.GetGenericTypeDefinition() != noContextDelegateDef) continue; + + var capturedProp = prop; + var resolveForTenant = forTenantMethod.MakeGenericMethod(classType); + + group.MapGet($"{className}/{capturedProp.Name}", async (string tenant, ConfigManager mgr) => + { + if (string.IsNullOrWhiteSpace(tenant)) + { + return Results.BadRequest("Tenant id is required."); + } + + try + { + await mgr.EnsureTenantInitializedAsync(tenant); + var instance = resolveForTenant.Invoke(null, [mgr, tenant])!; + var del = (Delegate)capturedProp.GetValue(instance)!; + return Results.Json(new { value = del.DynamicInvoke() }); + } + catch (TargetInvocationException ex) when (ex.InnerException is not null) + { + return Results.Problem(detail: ex.InnerException.Message, title: failureTitle, statusCode: 500); + } + }); + } + } +} diff --git a/src/Cocoar.Configuration.DI/ServiceRegistrationPlanner.cs b/src/Cocoar.Configuration.DI/ServiceRegistrationPlanner.cs index 5e17b5e..fe7a446 100644 --- a/src/Cocoar.Configuration.DI/ServiceRegistrationPlanner.cs +++ b/src/Cocoar.Configuration.DI/ServiceRegistrationPlanner.cs @@ -19,10 +19,25 @@ public static Dictionary CreatePlan(ConfigManager { var serviceRegistrationInfos = new Dictionary(); - // 1. Collect all types from rules + // 1. Collect all types from rules — EXCEPT types whose every rule is .TenantScoped(). + // Such a type has no value in the global pipeline; injecting it would be a captive-dependency bug + // (one tenant frozen into a long-lived consumer). Tenant-scoped values are obtained explicitly via + // GetConfigForTenant(id) / GetFeatureFlagsForTenant(id) instead (ADR-005 §5). A type that ALSO + // has a non-tenant-scoped (global base) rule stays injectable — that base value is a valid global config. + var tenantOnlyTypes = configManager.Rules + .GroupBy(rule => rule.ConcreteType) + .Where(group => group.All(rule => rule.Options?.TenantScoped == true)) + .Select(group => group.Key) + .ToHashSet(); + var typesFromRules = new HashSet(); foreach (var rule in configManager.Rules) { + if (tenantOnlyTypes.Contains(rule.ConcreteType)) + { + continue; + } + typesFromRules.Add(rule.ConcreteType); } diff --git a/src/Cocoar.Configuration.slnx b/src/Cocoar.Configuration.slnx index 389b20c..fc0bf56 100644 --- a/src/Cocoar.Configuration.slnx +++ b/src/Cocoar.Configuration.slnx @@ -11,6 +11,7 @@ + diff --git a/src/Cocoar.Configuration/Core/ConfigManager.cs b/src/Cocoar.Configuration/Core/ConfigManager.cs index dce6f53..5dc0e58 100644 --- a/src/Cocoar.Configuration/Core/ConfigManager.cs +++ b/src/Cocoar.Configuration/Core/ConfigManager.cs @@ -1,3 +1,4 @@ +using System.Collections.Concurrent; using System.Text.Json; using Cocoar.Capabilities; using Cocoar.Configuration.Configure; @@ -16,22 +17,31 @@ namespace Cocoar.Configuration.Core; -public sealed class ConfigManager : IConfigurationAccessor, IDisposable, IAsyncDisposable +public sealed class ConfigManager : IConfigurationAccessor, ITenantConfigurationAccessor, ILocalStorageHost, IDisposable, IAsyncDisposable { - private List _rules = null!; private List _setupDefinitions = null!; - private readonly List _ruleManagers = new(); - - private ConfigurationAccessor _accessor = null!; - private ReactiveConfigurationFactory _reactiveFactory = null!; - private ReactiveConfigManager _reactiveConfigManager = null!; private readonly ConfigManagerCapabilityScope _capabilityScope; - private ConfigurationEngine _engine = null!; - private ConfigurationState _state = null!; - private ProviderRegistry _providerRegistry = null!; private ExposureRegistry _bindingRegistry = null!; private ILogger _logger = NullLogger.Instance; private int _debounceMilliseconds = 300; + private IFlagsHealthSource? _flagsHealthSource; + private Func? _providerFactory; + + // The single global pipeline IS "the bundle without a tenant suffix" (ADR-005 §2). Each initialized tenant + // gets its own TenantPipeline alongside it, layered on the shared global base. The members below forward to + // the global bundle so existing method bodies are unchanged and the global path stays byte-identical. + private TenantPipeline _global = null!; + + // Per-tenant pipelines, materialized on demand (ADR-005 §4). The Lazy> gate guarantees a tenant + // is built exactly once even under concurrent InitializeTenantAsync/EnsureTenantInitializedAsync calls. + private readonly ConcurrentDictionary>> _tenants = new(); + + private List _rules => _global.Rules; + private List _ruleManagers => _global.RuleManagers; + private ConfigurationAccessor _accessor => _global.Accessor; + private ReactiveConfigurationFactory _reactiveFactory => _global.ReactiveFactory; + private ConfigurationEngine _engine => _global.Engine; + private ConfigurationState _state => _global.State; private int _initialized; @@ -82,7 +92,7 @@ internal void Configure( IFlagsHealthSource? flagsHealthSource = null) { // Apply test configuration overrides if present - _rules = ApplyTestConfigurationOverrides(configuredRules).ToList(); + var rules = ApplyTestConfigurationOverrides(configuredRules).ToList(); // Apply test setup overrides if present var effectiveSetup = ApplyTestSetupOverrides(setup); @@ -91,16 +101,24 @@ internal void Configure( _logger = logger ?? NullLogger.Instance; _debounceMilliseconds = debounceMilliseconds; - _state = new ConfigurationState(_ruleManagers, _rules, _logger, flagsHealthSource); - _providerRegistry = new ProviderRegistry(_logger, enableDiagnostics: false, factory: providerFactory); - _bindingRegistry = new ExposureRegistry(_setupDefinitions, _logger, _capabilityScope); + // Captured so tenant pipelines can be built later with the same health source / provider factory. + _flagsHealthSource = flagsHealthSource; + _providerFactory = providerFactory; - _accessor = new(_state, _bindingRegistry, _logger); - _accessor.SetCapabilityScope(_capabilityScope); - _reactiveConfigManager = new(_logger, _bindingRegistry); - _reactiveFactory = new(_reactiveConfigManager, _rules, _logger, this, _bindingRegistry); + // ExposureRegistry is SHARED (frozen) across the global and all tenant pipelines. + _bindingRegistry = new ExposureRegistry(_setupDefinitions, _logger, _capabilityScope); - _engine = new ConfigurationEngine(_state, _logger); + // Build the global pipeline (rule-suffix = none). It owns its own state/engine/accessor/reactive/rules + // and borrows the shared scope + binding registry. The global pipeline's recompute accessor is `this`. + _global = new TenantPipeline( + rules, + _capabilityScope, + _bindingRegistry, + _logger, + _debounceMilliseconds, + flagsHealthSource, + providerFactory, + reactiveOwner: this); } internal ConfigManager(Func rules, Func? setup = null, ILogger? logger = null, Func? providerFactory = null, int debounceMilliseconds = 300) @@ -146,18 +164,8 @@ internal ConfigManager Initialize() composer?.Build(); _capabilityScope.Owner.GetComposition()?.UsingEach(c => c.Apply()); - _engine.InitializeAndCompute( - _rules, - _ruleManagers, - _providerRegistry, - this, - _bindingRegistry, - _capabilityScope, - ScheduleRecompute, - _debounceMilliseconds); - - // Wire up the reactive config manager to use the backplane - _reactiveConfigManager.SetBackplane(_state.Backplane); + // The global pipeline's recompute accessor is `this` — byte-identical to before. + _global.Initialize(this, ScheduleRecompute); } return this; } @@ -173,18 +181,7 @@ internal async Task InitializeAsync(CancellationToken cancellatio composer?.Build(); _capabilityScope.Owner.GetComposition()?.UsingEach(c => c.Apply()); - await _engine.InitializeAndComputeAsync( - _rules, - _ruleManagers, - _providerRegistry, - this, - _bindingRegistry, - _capabilityScope, - ScheduleRecompute, - _debounceMilliseconds, - cancellationToken).ConfigureAwait(false); - - _reactiveConfigManager.SetBackplane(_state.Backplane); + await _global.InitializeAsync(this, ScheduleRecompute, cancellationToken).ConfigureAwait(false); } return this; } @@ -295,6 +292,12 @@ internal MutableJsonObject BuildBaseJson(Type configType, Func isExcludedLayer) + => BuildBaseJson(configType, isExcludedLayer); + + JsonElement? ILocalStorageHost.GetConfigAsJson(Type type) => GetConfigAsJson(type); + /// /// Gets a reactive wrapper for the specified configuration type. /// The returned emits the current value immediately on subscribe @@ -337,22 +340,168 @@ internal void ScheduleRecompute(int startIndex) => internal Task? CurrentRecomputeTask => _engine.CurrentRecomputeTask; + // ===== Tenant lifecycle (ADR-005 §4/§5, ITenantConfigurationAccessor) ===== + + /// + public Task InitializeTenantAsync(string tenantId, CancellationToken cancellationToken = default) + => GetOrBuildTenantAsync(tenantId, cancellationToken); + + /// + public Task EnsureTenantInitializedAsync(string tenantId, CancellationToken cancellationToken = default) + => GetOrBuildTenantAsync(tenantId, cancellationToken); + + /// + public bool IsTenantInitialized(string tenantId) + { + ArgumentException.ThrowIfNullOrWhiteSpace(tenantId); + return _tenants.TryGetValue(tenantId, out var lazy) + && lazy.IsValueCreated + && lazy.Value.IsCompletedSuccessfully + && lazy.Value.Result.IsInitialized; + } + + /// + public T? GetConfigForTenant(string tenantId) where T : class + => GetInitializedTenantOrThrow(tenantId).Accessor.GetConfig(); + + /// + public IReactiveConfig GetReactiveConfigForTenant(string tenantId) + { + var pipeline = GetInitializedTenantOrThrow(tenantId); + // Mirror the global GetReactiveConfig: the factory + ReactiveConfigManager are the tenant pipeline's + // own, and the value source reads the tenant accessor — so the reactive tracks THIS tenant's value. + return pipeline.ReactiveFactory.GetReactiveConfig(() => (T)pipeline.Accessor.GetConfig(typeof(T))); + } + + /// + public async Task RemoveTenantAsync(string tenantId, CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(tenantId); + if (!_tenants.TryRemove(tenantId, out var lazy)) + { + return; + } + + TenantPipeline pipeline; + try + { + // Always resolve the removed entry before disposing — even if its build had not been triggered yet. + // Accessing lazy.Value forces the (single) build to run if a concurrent initializer hadn't started it, + // so a pipeline that gets built after this removal cannot be left orphaned and never disposed. + pipeline = await lazy.Value.ConfigureAwait(false); + } + catch + { + return; // init faulted/cancelled — nothing was published, nothing to dispose + } + + await pipeline.DisposeAsync().ConfigureAwait(false); + } + + private Task GetOrBuildTenantAsync(string tenantId, CancellationToken cancellationToken) + { + ArgumentException.ThrowIfNullOrWhiteSpace(tenantId); + if (_initialized == 0) + { + throw new InvalidOperationException( + "ConfigManager has not been initialized. Tenants can only be initialized after the global pipeline is ready."); + } + + var lazy = _tenants.GetOrAdd( + tenantId, + id => new Lazy>(() => BuildTenantAsync(id, cancellationToken))); + var task = lazy.Value; + + // If a PREVIOUS build for this tenant completed-faulted, evict that exact entry (identity-checked, so we + // never drop a healthy retry inserted under the same key) and rebuild once. An in-flight build is returned + // as-is. Bounded to a single rebuild so an always-failing build can't spin. + if (task.IsCompleted && (task.IsFaulted || task.IsCanceled)) + { + _tenants.TryRemove(new KeyValuePair>>(tenantId, lazy)); + lazy = _tenants.GetOrAdd( + tenantId, + id => new Lazy>(() => BuildTenantAsync(id, cancellationToken))); + task = lazy.Value; + } + + return task; + } + + private async Task BuildTenantAsync(string tenantId, CancellationToken cancellationToken) + { + // Same flat rule list as the global pipeline; the tenant pipeline owns its own state/engine/accessor/ + // reactive/rule-managers and borrows the shared (frozen) capability scope + binding registry. + var pipeline = new TenantPipeline( + _global.Rules, + _capabilityScope, + _bindingRegistry, + _logger, + _debounceMilliseconds, + _flagsHealthSource, + _providerFactory, + reactiveOwner: this, + tenantId: tenantId); + + try + { + // Recompute uses the pipeline's OWN accessor (Tenant = id): .TenantScoped() rules run and tenant-varying + // factories interpolate the id. Each tenant runs the FULL flat rule list with its own provider instances + // and own change subscriptions. This is the deliberate v1 model (ADR-005 §6): it is correct AND gives + // automatic fan-out — a live global base source (file/observable/http) propagates to every initialized + // tenant through that tenant's own subscription, with no cross-pipeline coordinator and none of the + // lock-ordering hazards a shared seed-from-global path would carry. The trade-off is linear resource use + // (N tenants re-run the base); the seed-from-global sharing optimization is a documented, deferred TODO. + await pipeline.InitializeAsync( + pipeline.Accessor, + startIndex => pipeline.Engine.ScheduleRecompute(pipeline.RuleManagers, pipeline.Accessor, startIndex), + cancellationToken).ConfigureAwait(false); + + return pipeline; + } + catch + { + // Dispose the partially-built pipeline so a failed init leaks nothing. The faulted task stays cached + // until GetOrBuildTenantAsync evicts it (identity-checked) on the next call — no self-eviction here, + // which would risk an ABA race against a healthy retry inserted under the same key. + await pipeline.DisposeAsync().ConfigureAwait(false); + throw; + } + } + + private TenantPipeline GetInitializedTenantOrThrow(string tenantId) + { + ArgumentException.ThrowIfNullOrWhiteSpace(tenantId); + if (_tenants.TryGetValue(tenantId, out var lazy) && lazy.IsValueCreated && lazy.Value.IsCompletedSuccessfully) + { + return lazy.Value.Result; + } + + throw new InvalidOperationException( + $"Tenant '{tenantId}' is not initialized. Call InitializeTenantAsync/EnsureTenantInitializedAsync first."); + } + + /// + /// The initialized tenant pipeline, for in-assembly facades (e.g. per-tenant LocalStorage) that need the + /// tenant's own rule managers/host. Throws if the tenant is not initialized. + /// + internal TenantPipeline GetInitializedTenantPipeline(string tenantId) => GetInitializedTenantOrThrow(tenantId); + /// /// Disposes the configuration manager and all associated resources. /// After disposal, configuration methods will throw . /// public void Dispose() { - _engine?.Dispose(); - _reactiveConfigManager?.Dispose(); - _state?.Dispose(); - - foreach (var rm in _ruleManagers.ToArray()) + foreach (var lazy in _tenants.Values) { - Safety.DisposeQuietly(rm); + if (lazy.IsValueCreated && lazy.Value.IsCompletedSuccessfully) + { + Safety.DisposeQuietly(lazy.Value.Result); + } } + _tenants.Clear(); - _ruleManagers.Clear(); + _global?.Dispose(); Interlocked.Exchange(ref _initialized, 0); } @@ -363,16 +512,31 @@ public void Dispose() /// public async ValueTask DisposeAsync() { - if (_engine != null) await _engine.DisposeAsync().ConfigureAwait(false); - _reactiveConfigManager?.Dispose(); - _state?.Dispose(); - - foreach (var rm in _ruleManagers.ToArray()) + foreach (var lazy in _tenants.Values) { - Safety.DisposeQuietly(rm); + if (!lazy.IsValueCreated) + { + continue; + } + + TenantPipeline? pipeline = null; + try + { + pipeline = await lazy.Value.ConfigureAwait(false); + } + catch + { + // faulted/cancelled init — nothing to dispose + } + + if (pipeline != null) + { + await pipeline.DisposeAsync().ConfigureAwait(false); + } } + _tenants.Clear(); - _ruleManagers.Clear(); + if (_global != null) await _global.DisposeAsync().ConfigureAwait(false); Interlocked.Exchange(ref _initialized, 0); } diff --git a/src/Cocoar.Configuration/Core/ConfigurationAccessor.cs b/src/Cocoar.Configuration/Core/ConfigurationAccessor.cs index d09828d..8db513b 100644 --- a/src/Cocoar.Configuration/Core/ConfigurationAccessor.cs +++ b/src/Cocoar.Configuration/Core/ConfigurationAccessor.cs @@ -28,13 +28,18 @@ internal partial class ConfigurationAccessor : IConfigurationAccessor public ConfigurationAccessor( ConfigurationState state, ExposureRegistry bindingRegistry, - ILogger logger) + ILogger logger, + string? tenant = null) { _state = state; _bindingRegistry = bindingRegistry; _logger = logger; + Tenant = tenant; } + /// + public string? Tenant { get; } + internal void SetCapabilityScope(ConfigManagerCapabilityScope capabilityScope) { _capabilityScope = capabilityScope; diff --git a/src/Cocoar.Configuration/Core/ILocalStorageHost.cs b/src/Cocoar.Configuration/Core/ILocalStorageHost.cs new file mode 100644 index 0000000..7cc7ea1 --- /dev/null +++ b/src/Cocoar.Configuration/Core/ILocalStorageHost.cs @@ -0,0 +1,18 @@ +using System.Text.Json; +using Cocoar.Configuration.Rules; +using Cocoar.Json.Mutable; + +namespace Cocoar.Configuration.Core; + +/// +/// The pipeline context a LocalStorage overlay adapter needs: the merged "base" JSON below the overlay layer +/// (for sparse-write key alignment and provenance) and the effective snapshot JSON. Implemented by +/// (the global pipeline) and TenantPipeline (a tenant), so the one adapter +/// serves both global and per-tenant overlays (ADR-005 §7). +/// +internal interface ILocalStorageHost +{ + MutableJsonObject BuildBaseJson(Type configType, Func isExcludedLayer); + + JsonElement? GetConfigAsJson(Type type); +} diff --git a/src/Cocoar.Configuration/Core/TenantPipeline.cs b/src/Cocoar.Configuration/Core/TenantPipeline.cs new file mode 100644 index 0000000..bd95682 --- /dev/null +++ b/src/Cocoar.Configuration/Core/TenantPipeline.cs @@ -0,0 +1,167 @@ +using System.Text.Json; +using Cocoar.Capabilities; +using Cocoar.Configuration.Configure; +using Cocoar.Configuration.Flags.Internal; +using Cocoar.Configuration.Fluent; +using Cocoar.Configuration.Testing; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Cocoar.Configuration.Providers.Abstractions; +using Cocoar.Configuration.Health; +using Cocoar.Configuration.Infrastructure; +using Cocoar.Configuration.Rules; +using Cocoar.Configuration.Reactive; +using Cocoar.Configuration.Utilities; +using Cocoar.Json.Mutable; + +namespace Cocoar.Configuration.Core; + +/// +/// A self-contained configuration pipeline bundle: state, engine, accessor, reactive factory, provider +/// registry and rule managers. The single global pipeline and (later) each tenant pipeline are instances +/// of this same type — "global" is simply the bundle without a tenant suffix. +/// +/// Shared, read-only infrastructure (the capability scope and the frozen ) +/// is injected and never owned/disposed here — it is owned by the and shared +/// across all pipelines (ADR-005 §2). Each pipeline owns its own state/engine/accessor/reactive/rules. +/// +/// +internal sealed class TenantPipeline : ILocalStorageHost, IDisposable, IAsyncDisposable +{ + private readonly ConfigManagerCapabilityScope _capabilityScope; // shared (read-only after Initialize) + private readonly ExposureRegistry _bindingRegistry; // shared (frozen) + private readonly ILogger _logger; // shared + private readonly int _debounceMilliseconds; // shared value + + internal List Rules { get; } + internal List RuleManagers { get; } = new(); + internal ConfigurationState State { get; } + internal ProviderRegistry ProviderRegistry { get; } + internal ConfigurationAccessor Accessor { get; } + internal ReactiveConfigManager ReactiveConfigManager { get; } + internal ReactiveConfigurationFactory ReactiveFactory { get; } + internal ConfigurationEngine Engine { get; } + internal MasterBackplane Backplane => State.Backplane; + + private int _initialized; + internal bool IsInitialized => Volatile.Read(ref _initialized) != 0; + + /// The tenant this pipeline resolves for, or null for the global (tenant-agnostic) pipeline. + internal string? TenantId { get; } + + internal TenantPipeline( + List rules, + ConfigManagerCapabilityScope capabilityScope, + ExposureRegistry bindingRegistry, + ILogger logger, + int debounceMilliseconds, + IFlagsHealthSource? flagsHealthSource, + Func? providerFactory, + ConfigManager reactiveOwner, + string? tenantId = null) + { + Rules = rules; + _capabilityScope = capabilityScope; + _bindingRegistry = bindingRegistry; + _logger = logger; + _debounceMilliseconds = debounceMilliseconds; + TenantId = tenantId; + + // Construction order is byte-identical to the former ConfigManager.Configure sequence. + // The accessor carries the tenant id so .TenantScoped() rules run (and tenant-varying factories + // interpolate it) in a tenant pipeline, while the global pipeline keeps tenant == null. + State = new ConfigurationState(RuleManagers, Rules, _logger, flagsHealthSource); + ProviderRegistry = new ProviderRegistry(_logger, enableDiagnostics: false, factory: providerFactory); + Accessor = new ConfigurationAccessor(State, _bindingRegistry, _logger, tenantId); + Accessor.SetCapabilityScope(_capabilityScope); + ReactiveConfigManager = new ReactiveConfigManager(_logger, _bindingRegistry); + + // Reactive reads bind to the global ConfigManager for the global pipeline (byte-identical) and to this + // pipeline's OWN accessor for a tenant pipeline; the backplane is always this pipeline's own (the global + // ConfigManager's backplane IS the global pipeline's State.Backplane, so global stays byte-identical). + IConfigurationAccessor reactiveAccessor = tenantId is null ? reactiveOwner : Accessor; + ReactiveFactory = new ReactiveConfigurationFactory( + ReactiveConfigManager, Rules, _logger, reactiveAccessor, () => State.Backplane, _bindingRegistry); + Engine = new ConfigurationEngine(State, _logger); + } + + /// + /// The handed to the engine for recompute-window fallback reads and + /// provider option factories. For the global pipeline this is the owning + /// (byte-identical to before); for a tenant pipeline it is the tenant's own accessor. + /// + internal void Initialize(IConfigurationAccessor recomputeAccessor, Action scheduleRecompute) + { + Engine.InitializeAndCompute( + Rules, RuleManagers, ProviderRegistry, recomputeAccessor, + _bindingRegistry, _capabilityScope, scheduleRecompute, _debounceMilliseconds); + ReactiveConfigManager.SetBackplane(State.Backplane); + Volatile.Write(ref _initialized, 1); + } + + internal async Task InitializeAsync(IConfigurationAccessor recomputeAccessor, Action scheduleRecompute, CancellationToken cancellationToken) + { + await Engine.InitializeAndComputeAsync( + Rules, RuleManagers, ProviderRegistry, recomputeAccessor, + _bindingRegistry, _capabilityScope, scheduleRecompute, _debounceMilliseconds, cancellationToken).ConfigureAwait(false); + ReactiveConfigManager.SetBackplane(State.Backplane); + Volatile.Write(ref _initialized, 1); + } + + // ILocalStorageHost — a per-tenant LocalStorage overlay computes its base/effective JSON against THIS + // pipeline's own rule managers and snapshot (mirrors ConfigManager.BuildBaseJson over the global managers). + public MutableJsonObject BuildBaseJson(Type configType, Func isExcludedLayer) + { + ArgumentNullException.ThrowIfNull(configType); + ArgumentNullException.ThrowIfNull(isExcludedLayer); + + var merged = new MutableJsonObject(); + foreach (var manager in RuleManagers) + { + if (isExcludedLayer(manager)) + { + break; // the base is everything strictly below the overlay layer + } + + if (manager.TypeDefinition != configType) + { + continue; + } + + if (manager.LastJsonContribution is { } contribution) + { + MutableJsonMerge.Merge(merged, contribution); + } + } + + return merged; + } + + public JsonElement? GetConfigAsJson(Type type) => State.GetConfigurationAsJson(type); + + public void Dispose() + { + Engine?.Dispose(); + ReactiveConfigManager?.Dispose(); + State?.Dispose(); + + foreach (var rm in RuleManagers.ToArray()) + Safety.DisposeQuietly(rm); + + RuleManagers.Clear(); + Volatile.Write(ref _initialized, 0); + } + + public async ValueTask DisposeAsync() + { + if (Engine != null) await Engine.DisposeAsync().ConfigureAwait(false); + ReactiveConfigManager?.Dispose(); + State?.Dispose(); + + foreach (var rm in RuleManagers.ToArray()) + Safety.DisposeQuietly(rm); + + RuleManagers.Clear(); + Volatile.Write(ref _initialized, 0); + } +} diff --git a/src/Cocoar.Configuration/Flags/ConfigManagerFlagsExtensions.cs b/src/Cocoar.Configuration/Flags/ConfigManagerFlagsExtensions.cs index 0861e60..412a94b 100644 --- a/src/Cocoar.Configuration/Flags/ConfigManagerFlagsExtensions.cs +++ b/src/Cocoar.Configuration/Flags/ConfigManagerFlagsExtensions.cs @@ -15,6 +15,8 @@ public static class ConfigManagerFlagsExtensions private static readonly Type ReactiveConfigDef = typeof(IReactiveConfig<>); private static readonly MethodInfo GetReactiveConfigMethod = typeof(ConfigManager).GetMethod(nameof(ConfigManager.GetReactiveConfig))!; + private static readonly MethodInfo GetReactiveConfigForTenantMethod = + typeof(ConfigManager).GetMethod(nameof(ConfigManager.GetReactiveConfigForTenant))!; /// /// Resolves the singleton instance of the specified feature flag class. @@ -45,11 +47,63 @@ public static T GetEntitlements(this ConfigManager manager) where T : class } /// - /// Constructs a flag or entitlement class instance by resolving its constructor parameters. - /// Only dependencies are supported — they are resolved - /// from the ConfigManager. Parameterless constructors are also supported. + /// Resolves the per-tenant singleton instance of the specified feature flag class — the SAME generated + /// class constructed with the tenant's own , so the flag evaluates against + /// that tenant's effective config (ADR-005 §7). No source-generator change. Cached per (tenant, T). + /// + /// A feature flag class registered via UseFeatureFlags. + /// Flags not configured, or the tenant is not initialized. + public static T GetFeatureFlagsForTenant(this ConfigManager manager, string tenantId) where T : class + { + ArgumentException.ThrowIfNullOrWhiteSpace(tenantId); + var setup = manager.FlagsSetup + ?? throw new InvalidOperationException( + "UseFeatureFlags has not been configured. Call .UseFeatureFlags() in ConfigManager.Create()."); + EnsureTenantInitialized(manager, tenantId); + + return (T)setup.TenantInstanceCache.GetOrAdd((tenantId, typeof(T)), _ => CreateInstanceForTenant(manager, tenantId)); + } + + /// + /// Resolves the per-tenant singleton instance of the specified entitlement class — constructed with the + /// tenant's own (ADR-005 §7). No source-generator change. Cached per (tenant, T). + /// + /// An entitlement class registered via UseEntitlements. + /// Entitlements not configured, or the tenant is not initialized. + public static T GetEntitlementsForTenant(this ConfigManager manager, string tenantId) where T : class + { + ArgumentException.ThrowIfNullOrWhiteSpace(tenantId); + var setup = manager.EntitlementsSetup + ?? throw new InvalidOperationException( + "UseEntitlements has not been configured. Call .UseEntitlements() in ConfigManager.Create()."); + EnsureTenantInitialized(manager, tenantId); + + return (T)setup.TenantInstanceCache.GetOrAdd((tenantId, typeof(T)), _ => CreateInstanceForTenant(manager, tenantId)); + } + + /// + /// Constructs a flag or entitlement class instance, resolving its + /// dependencies from the global ConfigManager. Parameterless constructors are also supported. /// private static T CreateInstance(ConfigManager manager) + => Construct(p => ResolveReactiveParameter(p, configType => + GetReactiveConfigMethod.MakeGenericMethod(configType).Invoke(manager, null)!)); + + private static void EnsureTenantInitialized(ConfigManager manager, string tenantId) + { + if (!manager.IsTenantInitialized(tenantId)) + { + throw new InvalidOperationException( + $"Tenant '{tenantId}' is not initialized. Call InitializeTenantAsync/EnsureTenantInitializedAsync first."); + } + } + + /// Tenant variant of — resolves the tenant's IReactiveConfig<T>. + private static T CreateInstanceForTenant(ConfigManager manager, string tenantId) + => Construct(p => ResolveReactiveParameter(p, configType => + GetReactiveConfigForTenantMethod.MakeGenericMethod(configType).Invoke(manager, [tenantId])!)); + + private static T Construct(Func resolveParameter) { var type = typeof(T); @@ -61,20 +115,17 @@ private static T CreateInstance(ConfigManager manager) ?? throw new InvalidOperationException( $"No public constructor found on '{type.Name}'."); - var args = ctor.GetParameters() - .Select(p => ResolveConstructorParameter(manager, p)) - .ToArray(); - + var args = ctor.GetParameters().Select(resolveParameter).ToArray(); return (T)ctor.Invoke(args); } - private static object ResolveConstructorParameter(ConfigManager manager, ParameterInfo param) + private static object ResolveReactiveParameter(ParameterInfo param, Func resolveReactive) { var paramType = param.ParameterType; if (paramType.IsGenericType && paramType.GetGenericTypeDefinition() == ReactiveConfigDef) { var configType = paramType.GetGenericArguments()[0]; - return GetReactiveConfigMethod.MakeGenericMethod(configType).Invoke(manager, null)!; + return resolveReactive(configType); } throw new InvalidOperationException( diff --git a/src/Cocoar.Configuration/Flags/Internal/EntitlementsSetupData.cs b/src/Cocoar.Configuration/Flags/Internal/EntitlementsSetupData.cs index be8ffe0..5734155 100644 --- a/src/Cocoar.Configuration/Flags/Internal/EntitlementsSetupData.cs +++ b/src/Cocoar.Configuration/Flags/Internal/EntitlementsSetupData.cs @@ -11,6 +11,10 @@ internal sealed class EntitlementsSetupData { internal readonly ConcurrentDictionary InstanceCache = new(); + // Per-(tenant, entitlement-type) singletons: the SAME generated class constructed with each tenant's own + // IReactiveConfig (ADR-005 §7). Distinct from the global InstanceCache so tenants never alias the global. + internal readonly ConcurrentDictionary<(string Tenant, Type Type), object> TenantInstanceCache = new(); + public required EntitlementsDescriptors Descriptors { get; init; } public required EntitlementRegistration[] Registrations { get; init; } public required IReadOnlyList GlobalResolvers { get; init; } diff --git a/src/Cocoar.Configuration/Flags/Internal/FlagsSetupData.cs b/src/Cocoar.Configuration/Flags/Internal/FlagsSetupData.cs index b766bda..b5f92c4 100644 --- a/src/Cocoar.Configuration/Flags/Internal/FlagsSetupData.cs +++ b/src/Cocoar.Configuration/Flags/Internal/FlagsSetupData.cs @@ -11,6 +11,10 @@ internal sealed class FlagsSetupData { internal readonly ConcurrentDictionary InstanceCache = new(); + // Per-(tenant, flag-type) singletons: the SAME generated flag class constructed with each tenant's own + // IReactiveConfig (ADR-005 §7). Distinct from the global InstanceCache so tenants never alias the global. + internal readonly ConcurrentDictionary<(string Tenant, Type Type), object> TenantInstanceCache = new(); + public required FeatureFlagsDescriptors Descriptors { get; init; } public required FlagRegistration[] Registrations { get; init; } public required IReadOnlyList GlobalResolvers { get; init; } diff --git a/src/Cocoar.Configuration/Fluent/ProviderRuleBuilder.cs b/src/Cocoar.Configuration/Fluent/ProviderRuleBuilder.cs index dc73a43..b221db6 100644 --- a/src/Cocoar.Configuration/Fluent/ProviderRuleBuilder.cs +++ b/src/Cocoar.Configuration/Fluent/ProviderRuleBuilder.cs @@ -35,7 +35,8 @@ public ConfigRule Build() var opts = new ConfigRuleOptions( Required: IsRequired, UseWhen: UseWhen, - Name: Name) + Name: Name, + TenantScoped: IsTenantScoped) .WithMount(MountPath) .WithSelect(SelectPath); diff --git a/src/Cocoar.Configuration/Fluent/RuleBuilderBase.cs b/src/Cocoar.Configuration/Fluent/RuleBuilderBase.cs index 6007b73..6e312f7 100644 --- a/src/Cocoar.Configuration/Fluent/RuleBuilderBase.cs +++ b/src/Cocoar.Configuration/Fluent/RuleBuilderBase.cs @@ -6,6 +6,14 @@ public abstract class RuleBuilderBase where TBuilder : RuleBuilderBase { protected bool IsRequired { get; set; } + + /// + /// Static marker set by . Distinct from the predicate (which + /// drives runtime skip): the DI planner and analyzers read this to exclude purely tenant-scoped types from + /// the global injection plan (ADR-005 §5) without having to evaluate the predicate. + /// + protected bool IsTenantScoped { get; set; } + protected Func? UseWhen { get; set; } protected Type? ConcreteType { get; set; } protected string? MountPath { get; set; } @@ -36,6 +44,24 @@ public TBuilder When(Func predicate) return (TBuilder)this; } + /// + /// Marks this rule as tenant-scoped: it runs only when the configuration is resolved for a tenant + /// ( is present) and is skipped in the global, tenant-agnostic + /// pipeline. Shorthand for .When(a => !string.IsNullOrWhiteSpace(a.Tenant)), composed (AND) with any + /// existing predicate. Use together with a tenant-varying factory, e.g. + /// .FromFile(a => $"db.{a.Tenant}.json").TenantScoped(). + /// + /// This builder for chaining. + public TBuilder TenantScoped() + { + IsTenantScoped = true; + var existing = UseWhen; + UseWhen = existing is null + ? static a => !string.IsNullOrWhiteSpace(a.Tenant) + : a => existing(a) && !string.IsNullOrWhiteSpace(a.Tenant); + return (TBuilder)this; + } + /// /// Sets the JSON mount path used when merging this rule's output into the target configuration type. /// Values from this rule are nested under the given path before deserialization. diff --git a/src/Cocoar.Configuration/Providers/LocalStorageProvider/LocalStorageAdapter.cs b/src/Cocoar.Configuration/Providers/LocalStorageProvider/LocalStorageAdapter.cs index f794dab..604df84 100644 --- a/src/Cocoar.Configuration/Providers/LocalStorageProvider/LocalStorageAdapter.cs +++ b/src/Cocoar.Configuration/Providers/LocalStorageProvider/LocalStorageAdapter.cs @@ -20,12 +20,14 @@ namespace Cocoar.Configuration.Providers; internal sealed class LocalStorageAdapter : ILocalStorage, ILocalStorageOverlay, IDisposable where T : class { - private readonly ConfigManager _configManager; + private readonly ILocalStorageHost _host; private readonly LocalStorageStore _store; - public LocalStorageAdapter(ConfigManager configManager, LocalStorageStore store) + // _host is the pipeline this overlay belongs to: the global ConfigManager, or a TenantPipeline for a + // per-tenant overlay (ADR-005 §7). Both supply base/effective JSON over their OWN rule managers/snapshot. + public LocalStorageAdapter(ILocalStorageHost host, LocalStorageStore store) { - _configManager = configManager ?? throw new ArgumentNullException(nameof(configManager)); + _host = host ?? throw new ArgumentNullException(nameof(host)); _store = store ?? throw new ArgumentNullException(nameof(store)); } @@ -76,8 +78,8 @@ public Task SetSecretAsync(Expression>> select public async Task> DescribeAsync(CancellationToken ct = default) { - var baseElement = ToJsonElement(_configManager.BuildBaseJson(typeof(T), IsThisLayer)); - var effective = _configManager.GetConfigAsJson(typeof(T)); + var baseElement = ToJsonElement(_host.BuildBaseJson(typeof(T), IsThisLayer)); + var effective = _host.GetConfigAsJson(typeof(T)); var overlayNode = await ReadOverlayAsync(ct).ConfigureAwait(false); var overriddenPaths = new HashSet(StringComparer.Ordinal); @@ -112,7 +114,7 @@ public async Task> DescribeAsync(CancellationToken public async Task SetAsync(string keyPath, JsonNode? value, CancellationToken ct = default) { ValidateKeyPath(keyPath); - var baseDom = _configManager.BuildBaseJson(typeof(T), IsThisLayer); + var baseDom = _host.BuildBaseJson(typeof(T), IsThisLayer); await _store.UpdateBytesAsync(bytes => SparseOverlayMutator.Set(bytes, keyPath, value, baseDom), ct) .ConfigureAwait(false); } @@ -133,7 +135,7 @@ public async Task SetSecretEnvelopeAsync(string keyPath, JsonNode envelope, Canc nameof(envelope)); } - var baseDom = _configManager.BuildBaseJson(typeof(T), IsThisLayer); + var baseDom = _host.BuildBaseJson(typeof(T), IsThisLayer); await _store.UpdateBytesAsync(bytes => SparseOverlayMutator.Set(bytes, keyPath, envelope, baseDom), ct) .ConfigureAwait(false); } diff --git a/src/Cocoar.Configuration/Providers/LocalStorageProvider/LocalStorageRulesExtensions.cs b/src/Cocoar.Configuration/Providers/LocalStorageProvider/LocalStorageRulesExtensions.cs index 22851a3..b2195eb 100644 --- a/src/Cocoar.Configuration/Providers/LocalStorageProvider/LocalStorageRulesExtensions.cs +++ b/src/Cocoar.Configuration/Providers/LocalStorageProvider/LocalStorageRulesExtensions.cs @@ -1,3 +1,4 @@ +using System.Collections.Concurrent; using Cocoar.Configuration.Core; using Cocoar.Configuration.Fluent; @@ -59,12 +60,18 @@ public static ProviderRuleBuilder(); var storageKey = typeof(T).FullName ?? typeof(T).Name; return new( accessor => { + var tenantKey = accessor.Tenant ?? string.Empty; + stores.TryGetValue(tenantKey, out var store); var currentBackend = store?.Backend; var backend = backendFactory(accessor, currentBackend); if (store is null) @@ -73,6 +80,7 @@ public static ProviderRuleBuilder +/// Per-tenant LocalStorage write facade (ADR-005 §7). The global write facade is ILocalStorage<T> +/// resolved from DI; the per-tenant facade is obtained explicitly with the tenant id, exactly like the rest of +/// the tenant surface (GetConfigForTenant, GetFeatureFlagsForTenant, …). +/// +public static class TenantLocalStorageExtensions +{ + /// + /// Returns the LocalStorage write facade for a tenant's overlay of . Writes target + /// the tenant pipeline's own store and trigger only that tenant's recompute. For per-tenant isolation the + /// rule must use a per-tenant backend, e.g. + /// rules.For<T>().FromLocalStorage(a => BackendFor(a.Tenant)).TenantScoped() — the factory overload + /// keys its store by accessor.Tenant, so each tenant gets its own store/backend. + /// + /// + /// The tenant is not initialized, or it has no LocalStorage rule for . + /// + public static ILocalStorage GetLocalStorageForTenant(this ConfigManager manager, string tenantId) + where T : class + { + ArgumentNullException.ThrowIfNull(manager); + ArgumentException.ThrowIfNullOrWhiteSpace(tenantId); + + var pipeline = manager.GetInitializedTenantPipeline(tenantId); + + // The tenant pipeline's store for T (last LocalStorage rule wins — highest-precedence overlay). + LocalStorageStore? store = null; + foreach (var ruleManager in pipeline.RuleManagers) + { + if (ruleManager.CurrentProvider is LocalStorageProvider provider + && provider.Store.ConfigurationType == typeof(T)) + { + store = provider.Store; + } + } + + if (store is null) + { + throw new InvalidOperationException( + $"No LocalStorage rule is registered for '{typeof(T).Name}' in tenant '{tenantId}'. " + + $"Add rules.For<{typeof(T).Name}>().FromLocalStorage(...).TenantScoped()."); + } + + // The TenantPipeline is the ILocalStorageHost: base/effective JSON is computed over the tenant's own + // rule managers and snapshot, so provenance and sparse-write key alignment are per tenant. + return new LocalStorageAdapter(pipeline, store); + } +} diff --git a/src/Cocoar.Configuration/Reactive/ReactiveConfigurationFactory.cs b/src/Cocoar.Configuration/Reactive/ReactiveConfigurationFactory.cs index c04e973..717a843 100644 --- a/src/Cocoar.Configuration/Reactive/ReactiveConfigurationFactory.cs +++ b/src/Cocoar.Configuration/Reactive/ReactiveConfigurationFactory.cs @@ -21,17 +21,23 @@ internal static partial class ReactiveConfigurationFactoryLog public static partial void SkippingNonClassType(this ILogger logger, Type Type); } +// `accessor` + backplaneAccessor (instead of a concrete ConfigManager) so a tenant pipeline builds its +// reactive configs over ITS OWN accessor/backplane (ADR-005 §7). The global pipeline passes the owning +// ConfigManager as the accessor and that manager's backplane — byte-identical to before. +// NOTE: this field is intentionally NOT named `configAccessor` — several methods below take a local +// `Func configAccessor` (the value closure), which would shadow it and silently mis-bind the delegate. internal class ReactiveConfigurationFactory( ReactiveConfigManager reactiveConfigManager, List rules, ILogger logger, - ConfigManager configManager, + IConfigurationAccessor accessor, + Func backplaneAccessor, ExposureRegistry bindingRegistry) { private static readonly MethodInfo _getReactiveConfigMethod = typeof(ReactiveConfigManager).GetMethod(nameof(ReactiveConfigManager.GetReactiveConfig))!; private static readonly MethodInfo _getConfigMethod = - typeof(ConfigManager).GetMethod(nameof(ConfigManager.GetConfig), Type.EmptyTypes)!; + typeof(IConfigurationAccessor).GetMethod(nameof(IConfigurationAccessor.GetConfig), Type.EmptyTypes)!; public IReactiveConfig GetReactiveConfig(Func configAccessor) { @@ -80,7 +86,7 @@ private IReactiveConfig CreateReactiveConfigForConcreteType).MakeGenericType(concreteType); - var concreteAccessor = Delegate.CreateDelegate(concreteFuncType, configManager, concreteAccessorMethod); + var concreteAccessor = Delegate.CreateDelegate(concreteFuncType, accessor, concreteAccessorMethod); // Get the reactive config for the concrete type var reactiveMethod = _getReactiveConfigMethod.MakeGenericMethod(concreteType); @@ -160,7 +166,7 @@ private object CreateTupleReactiveConfig(Type tupleType) var accessorMethod = _getConfigMethod.MakeGenericMethod(typeToPrime); var funcType = typeof(Func<>).MakeGenericType(typeToPrime); - var accessorDelegate = Delegate.CreateDelegate(funcType, configManager, accessorMethod); + var accessorDelegate = Delegate.CreateDelegate(funcType, accessor, accessorMethod); _ = reactiveMethod.Invoke(reactiveConfigManager, [accessorDelegate]); } @@ -171,7 +177,7 @@ private object CreateTupleReactiveConfig(Type tupleType) } var generic = typeof(ReactiveTupleConfig<>).MakeGenericType(tupleType); - return Activator.CreateInstance(generic, configManager, reactiveConfigManager, logger, bindingRegistry)!; + return Activator.CreateInstance(generic, accessor, backplaneAccessor(), reactiveConfigManager, logger, bindingRegistry)!; } private static IEnumerable FlattenTuple(Type t) diff --git a/src/Cocoar.Configuration/Reactive/ReactiveTupleConfig.cs b/src/Cocoar.Configuration/Reactive/ReactiveTupleConfig.cs index 5b2180d..9c9d447 100644 --- a/src/Cocoar.Configuration/Reactive/ReactiveTupleConfig.cs +++ b/src/Cocoar.Configuration/Reactive/ReactiveTupleConfig.cs @@ -29,16 +29,22 @@ internal sealed class ReactiveTupleConfig : IReactiveConfig, IDi private readonly IObservable _observable; private readonly Func _builder; private readonly Type[] _elementTypes; - private readonly ConfigManager _configManager; + private readonly IConfigurationAccessor _configAccessor; + private readonly MasterBackplane _backplane; private readonly Infrastructure.ExposureRegistry? _bindingRegistry; + // Loosened from a concrete ConfigManager to (accessor, backplane) so a tenant pipeline can build a tuple + // reactive over ITS OWN accessor + backplane (ADR-005 §7). The global pipeline still binds the owning + // ConfigManager as the accessor and that manager's backplane — byte-identical to before. public ReactiveTupleConfig( - ConfigManager configManager, + IConfigurationAccessor configAccessor, + MasterBackplane backplane, ReactiveConfigManager reactiveConfigManager, ILogger logger, Infrastructure.ExposureRegistry? bindingRegistry = null) { - _configManager = configManager; + _configAccessor = configAccessor; + _backplane = backplane; _logger = logger; _bindingRegistry = bindingRegistry; (_elementTypes, _builder) = TupleShapeCache.Get(typeof(TTuple)); @@ -51,7 +57,7 @@ public ReactiveTupleConfig( var missing = new List(); for (var i = 0; i < _elementTypes.Length; i++) { - var val = configManager.GetConfig(_elementTypes[i]); + var val = configAccessor.GetConfig(_elementTypes[i]); if (val is null) { missing.Add(_elementTypes[i].Name); @@ -67,7 +73,7 @@ public ReactiveTupleConfig( // Create observable from the backplane's snapshot stream // This provides atomicity - all tuple elements update together // Source (MasterBackplane) never errors, so no Catch/Retry needed - _observable = CreateTupleObservable(configManager); + _observable = CreateTupleObservable(backplane); _subscription = _observable.Subscribe(_ => { }, _ => { }); } @@ -81,7 +87,7 @@ public TTuple CurrentValue var values = new object?[_elementTypes.Length]; for (var i = 0; i < _elementTypes.Length; i++) { - values[i] = _configManager.GetConfig(_elementTypes[i]); + values[i] = _configAccessor.GetConfig(_elementTypes[i]); } return (TTuple)_builder(values); } @@ -95,7 +101,7 @@ public TTuple CurrentValue public IDisposable Subscribe(IObserver observer) => _observable.Subscribe(observer); - private IObservable CreateTupleObservable(ConfigManager configManager) + private IObservable CreateTupleObservable(MasterBackplane backplane) { // Access the backplane through the state (after initialization) // The backplane provides atomic updates for all types @@ -104,7 +110,7 @@ private IObservable CreateTupleObservable(ConfigManager configManager) TTuple? previousTuple = null; // Subscribe to the backplane's snapshot stream - var snapshotStream = configManager.Backplane.SnapshotStream; + var snapshotStream = backplane.SnapshotStream; return snapshotStream.Subscribe(snapshot => { diff --git a/src/Cocoar.Configuration/Rules/ConfigRuleOptions.cs b/src/Cocoar.Configuration/Rules/ConfigRuleOptions.cs index 7c4849e..a22f24b 100644 --- a/src/Cocoar.Configuration/Rules/ConfigRuleOptions.cs +++ b/src/Cocoar.Configuration/Rules/ConfigRuleOptions.cs @@ -7,7 +7,8 @@ public sealed record ConfigRuleOptions( Func? UseWhen = null, string? MountPath = null, string? SelectPath = null, - string? Name = null) + string? Name = null, + bool TenantScoped = false) { public ConfigRuleOptions WithMount(string? mountPath) => this with { MountPath = string.IsNullOrWhiteSpace(mountPath) ? null : mountPath.Trim() }; diff --git a/src/Cocoar.Configuration/Rules/RuleManager.cs b/src/Cocoar.Configuration/Rules/RuleManager.cs index b7ad68d..6b03496 100644 --- a/src/Cocoar.Configuration/Rules/RuleManager.cs +++ b/src/Cocoar.Configuration/Rules/RuleManager.cs @@ -71,9 +71,9 @@ public RuleManager(ConfigRule rule, ILogger logger, ProviderRegistry registry) { LastFailureException = null; - if (ShouldSkipViaUseWhen(accessor)) + if (ShouldSkip(accessor)) { - return null; // Skip rule - When condition is false + return null; // Skip rule - tenant-scoped without a tenant, or When condition is false } var providerOptions = _rule.ResolveProviderOptions(accessor); @@ -131,8 +131,16 @@ public RuleManager(ConfigRule rule, ILogger logger, ProviderRegistry registry) } } - private bool ShouldSkipViaUseWhen(IConfigurationAccessor accessor) + private bool ShouldSkip(IConfigurationAccessor accessor) { + // A .TenantScoped() rule never runs in the global (tenant-agnostic) pipeline. Enforced via the static + // marker (not just the When predicate) so it holds regardless of how .When() and .TenantScoped() were + // ordered in the fluent chain — e.g. .TenantScoped().When(p) still skips when there is no tenant. + if (_rule.Options?.TenantScoped == true && string.IsNullOrWhiteSpace(accessor.Tenant)) + { + return MarkSkipped(); + } + if (_rule.Options?.UseWhen == null) { return false; @@ -143,6 +151,11 @@ private bool ShouldSkipViaUseWhen(IConfigurationAccessor accessor) return false; } + return MarkSkipped(); + } + + private bool MarkSkipped() + { _changeSubscription.Unsubscribe(); LastOutcome = RuleExecutionOutcome.Skipped; return true; diff --git a/src/tests/Cocoar.Configuration.AspNetCore.Tests/TenantFlagEvaluationEndpointTests.cs b/src/tests/Cocoar.Configuration.AspNetCore.Tests/TenantFlagEvaluationEndpointTests.cs new file mode 100644 index 0000000..49bcfcb --- /dev/null +++ b/src/tests/Cocoar.Configuration.AspNetCore.Tests/TenantFlagEvaluationEndpointTests.cs @@ -0,0 +1,77 @@ +using System.Net; +using System.Net.Http.Json; +using System.Text.Json; +using Cocoar.Configuration.AspNetCore; +using Cocoar.Configuration.DI; +using Cocoar.Configuration.Flags; +using Cocoar.Configuration.Providers; // FromStaticJson / FromStatic +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Xunit; + +namespace Cocoar.Configuration.AspNetCore.Tests; + +public sealed record TenantBillingCfg { public bool Premium { get; init; } } + +// Source-generated: the generator emits the ctor taking IReactiveConfig + the Config property. +public partial class TenantBillingFlags : IFeatureFlags +{ + public DateTimeOffset ExpiresAt => new(2099, 1, 1, 0, 0, 0, TimeSpan.Zero); + public FeatureFlag PremiumEnabled => () => Config.Premium; +} + +/// +/// (P6) AspNetCore tenant dimension: the per-tenant flag endpoint evaluates against the tenant in the route +/// segment, lazily warming the tenant up. Different tenants resolve different values from one rule set. +/// +public class TenantFlagEvaluationEndpointTests : IAsyncDisposable +{ + private IHost? _host; + private HttpClient? _client; + + private async Task CreateHost() + { + var builder = WebApplication.CreateBuilder(); + builder.Services.AddCocoarConfiguration(c => c + .UseConfiguration(rules => + [ + rules.For().FromStaticJson("""{ "Premium": false }"""), + rules.For().FromStatic(a => new TenantBillingCfg { Premium = a.Tenant == "acme" }).TenantScoped(), + ]) + .UseFeatureFlags(flags => [flags.Register()]) + .UseDebounce(25)); + builder.WebHost.UseTestServer(); + + var app = builder.Build(); + app.MapTenantFeatureFlagEndpoints(); + + await app.StartAsync(); + _host = app; + _client = app.GetTestClient(); + return _client; + } + + public async ValueTask DisposeAsync() + { + _client?.Dispose(); + if (_host != null) await _host.StopAsync(); + _host?.Dispose(); + GC.SuppressFinalize(this); + } + + [Fact] + public async Task TenantFlagEndpoint_EvaluatesPerTenant() + { + var client = await CreateHost(); + + var acme = await client.GetAsync("/tenants/acme/flags/TenantBillingFlags/PremiumEnabled"); + Assert.Equal(HttpStatusCode.OK, acme.StatusCode); + Assert.True((await acme.Content.ReadFromJsonAsync()).GetProperty("value").GetBoolean()); + + var other = await client.GetAsync("/tenants/other/flags/TenantBillingFlags/PremiumEnabled"); + Assert.Equal(HttpStatusCode.OK, other.StatusCode); + Assert.False((await other.Content.ReadFromJsonAsync()).GetProperty("value").GetBoolean()); + } +} diff --git a/src/tests/Cocoar.Configuration.Core.Tests/MultiTenant/TenantScopedRuleTests.cs b/src/tests/Cocoar.Configuration.Core.Tests/MultiTenant/TenantScopedRuleTests.cs new file mode 100644 index 0000000..25739bf --- /dev/null +++ b/src/tests/Cocoar.Configuration.Core.Tests/MultiTenant/TenantScopedRuleTests.cs @@ -0,0 +1,44 @@ +using Cocoar.Configuration.Core; +using Cocoar.Configuration.Core.Tests.TestUtilities; +using Cocoar.Configuration.Providers; // FromObservable extension + +namespace Cocoar.Configuration.Core.Tests.MultiTenant; + +[Trait("Category", "MultiTenant")] +[Trait("Type", "Unit")] +public class TenantScopedRuleTests +{ + public sealed record Foo + { + public string Value { get; init; } = ""; + } + + /// + /// In the global (tenant-agnostic) pipeline, is null, so a + /// .TenantScoped() rule must be skipped — its overlay does not apply and the base value wins. + /// + [Fact] + public async Task TenantScopedRule_IsSkipped_InGlobalPipeline() + { + using var mgr = ConfigManager.Create(c => c + .UseConfiguration(rules => + [ + rules.For().FromObservable("""{ "Value": "base" }"""), + rules.For().FromObservable("""{ "Value": "tenant-overlay" }""").TenantScoped(), + ]) + .UseDebounce(25)); + + await ActiveWaitHelpers.WaitUntilAsync(() => mgr.GetConfig() is not null, description: "init"); + + Assert.Equal("base", mgr.GetConfig()!.Value); // tenant overlay skipped (no tenant) + } + + [Fact] + public void GlobalAccessor_HasNullTenant() + { + using var mgr = ConfigManager.Create(c => c + .UseConfiguration(rules => [rules.For().FromObservable("""{ "Value": "x" }""")])); + + Assert.Null(((IConfigurationAccessor)mgr).Tenant); + } +} diff --git a/src/tests/Cocoar.Configuration.MultiTenant.Tests/Cocoar.Configuration.MultiTenant.Tests.csproj b/src/tests/Cocoar.Configuration.MultiTenant.Tests/Cocoar.Configuration.MultiTenant.Tests.csproj new file mode 100644 index 0000000..593bba7 --- /dev/null +++ b/src/tests/Cocoar.Configuration.MultiTenant.Tests/Cocoar.Configuration.MultiTenant.Tests.csproj @@ -0,0 +1,34 @@ + + + + net9.0 + enable + enable + true + + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + + + + diff --git a/src/tests/Cocoar.Configuration.MultiTenant.Tests/TenantConfigTests.cs b/src/tests/Cocoar.Configuration.MultiTenant.Tests/TenantConfigTests.cs new file mode 100644 index 0000000..ce8f5f2 --- /dev/null +++ b/src/tests/Cocoar.Configuration.MultiTenant.Tests/TenantConfigTests.cs @@ -0,0 +1,159 @@ +using Cocoar.Configuration.Core; +using Cocoar.Configuration.Providers; // FromObservable / FromStaticJson / FromStatic + +namespace Cocoar.Configuration.MultiTenant.Tests; + +/// +/// (b2) acceptance — the real engine (per-tenant TenantPipeline on the shared global base), exercised +/// through the production API. Replaces the POC's standalone-manager fake. +/// +[Trait("Category", "MultiTenant")] +[Trait("Type", "Unit")] +public class TenantConfigTests +{ + public sealed record Smtp + { + public string Host { get; init; } = ""; + public int Port { get; init; } + } + + public sealed record Geo + { + public string Region { get; init; } = ""; + } + + /// + /// Core per-tenant mechanism on the real engine: a shared global base, a .TenantScoped() sparse overlay + /// that wins per key while inheriting the rest in the tenant pipeline, and is skipped in the global pipeline. + /// + [Fact] + public async Task GetConfigForTenant_OverlayWinsPerKey_GlobalReadsBase() + { + using var mgr = ConfigManager.Create(c => c + .UseConfiguration(rules => + [ + rules.For().FromObservable("""{ "Host": "smtp.global", "Port": 25 }"""), + rules.For().FromObservable("""{ "Host": "smtp.tenant" }""").TenantScoped(), + ]) + .UseDebounce(25)); + + var tenants = (ITenantConfigurationAccessor)mgr; + await tenants.InitializeTenantAsync("acme"); + + // Global pipeline: tenant overlay is skipped (no tenant) -> base value wins. + Assert.Equal("smtp.global", mgr.GetConfig()!.Host); + Assert.Equal(25, mgr.GetConfig()!.Port); + + // Tenant pipeline: overlay wins on Host, Port INHERITED from base (sparse deep-merge). + var acme = mgr.GetConfigForTenant("acme")!; + Assert.Equal("smtp.tenant", acme.Host); + Assert.Equal(25, acme.Port); + + Assert.True(tenants.IsTenantInitialized("acme")); + Assert.False(tenants.IsTenantInitialized("nope")); + } + + /// + /// The tenant id flows into a tenant-varying rule factory (FromStatic(a => ... a.Tenant ...)), so two + /// tenants resolve different values from the SAME rule list. The global pipeline keeps the base (overlay skipped). + /// + [Fact] + public async Task TenantId_FlowsIntoRuleFactory_PerTenantValues() + { + using var mgr = ConfigManager.Create(c => c + .UseConfiguration(rules => + [ + rules.For().FromStaticJson("""{ "Region": "base" }"""), + rules.For().FromStatic(a => new Geo { Region = a.Tenant! }).TenantScoped(), + ]) + .UseDebounce(25)); + + var tenants = (ITenantConfigurationAccessor)mgr; + await tenants.InitializeTenantAsync("acme"); + await tenants.InitializeTenantAsync("globex"); + + Assert.Equal("base", mgr.GetConfig()!.Region); // global: overlay skipped + Assert.Equal("acme", mgr.GetConfigForTenant("acme")!.Region); + Assert.Equal("globex", mgr.GetConfigForTenant("globex")!.Region); + } + + /// Init is idempotent and safe under concurrent callers — a tenant is built exactly once. + [Fact] + public async Task InitializeTenant_IsIdempotent_AndConcurrencySafe() + { + using var mgr = ConfigManager.Create(c => c + .UseConfiguration(rules => [rules.For().FromStaticJson("""{ "Region": "base" }""")]) + .UseDebounce(25)); + + var tenants = (ITenantConfigurationAccessor)mgr; + + // 32 concurrent init calls for the same tenant must all succeed and observe the same pipeline. + await Task.WhenAll(Enumerable.Range(0, 32).Select(_ => tenants.EnsureTenantInitializedAsync("acme"))); + + Assert.True(tenants.IsTenantInitialized("acme")); + Assert.Equal("base", mgr.GetConfigForTenant("acme")!.Region); + } + + public sealed record Security { public bool MfaRequired { get; init; } } + + /// + /// "Non-negotiable" platform ceiling: a global rule placed AFTER the tenant overlay wins over the tenant — no + /// special tier, just list position (ADR-005 §3). The classic case: a tenant tries to disable MFA, the + /// platform forces it back on. In the global pipeline the tenant overlay is skipped, so the ceiling holds too. + /// + [Fact] + public async Task NonNegotiableGlobalRuleAfterTenantOverlay_WinsOverTenant() + { + using var mgr = ConfigManager.Create(c => c + .UseConfiguration(rules => + [ + rules.For().FromStaticJson("""{ "MfaRequired": false }"""), // base + rules.For().FromStatic(_ => new Security { MfaRequired = false }).TenantScoped(), // tenant tries to keep MFA off + rules.For().FromStaticJson("""{ "MfaRequired": true }"""), // platform ceiling (last → wins) + ]) + .UseDebounce(25)); + + var tenants = (ITenantConfigurationAccessor)mgr; + await tenants.InitializeTenantAsync("acme"); + + Assert.True(mgr.GetConfigForTenant("acme")!.MfaRequired); // ceiling beats the tenant + Assert.True(mgr.GetConfig()!.MfaRequired); // and holds globally + } + + /// + /// The tenant gate is robust to fluent ordering: .TenantScoped().When(p) (where .When overwrites + /// the composed predicate) still skips the rule in the global pipeline — the static TenantScoped marker, not + /// the predicate, enforces "no tenant ⇒ skip". The custom predicate still applies per tenant. + /// + [Fact] + public async Task TenantScoped_ThenWhen_StillSkipsGlobally_AndPredicateAppliesPerTenant() + { + using var mgr = ConfigManager.Create(c => c + .UseConfiguration(rules => + [ + rules.For().FromStaticJson("""{ "Region": "base" }"""), + rules.For().FromStatic(a => new Geo { Region = a.Tenant! }) + .TenantScoped() + .When(a => a.Tenant == "acme"), // wrong order on purpose; predicate also gates to "acme" + ]) + .UseDebounce(25)); + + var tenants = (ITenantConfigurationAccessor)mgr; + await tenants.InitializeTenantAsync("acme"); + await tenants.InitializeTenantAsync("other"); + + Assert.Equal("base", mgr.GetConfig()!.Region); // global: skipped via static gate + Assert.Equal("acme", mgr.GetConfigForTenant("acme")!.Region); // acme: predicate true → overlay + Assert.Equal("base", mgr.GetConfigForTenant("other")!.Region); // other: predicate false → base + } + + /// Reading a tenant that was never initialized is a clear error, not a silent null. + [Fact] + public void GetConfigForTenant_BeforeInit_Throws() + { + using var mgr = ConfigManager.Create(c => c + .UseConfiguration(rules => [rules.For().FromStaticJson("""{ "Region": "base" }""")])); + + Assert.Throws(() => mgr.GetConfigForTenant("ghost")); + } +} diff --git a/src/tests/Cocoar.Configuration.MultiTenant.Tests/TenantDiExclusionTests.cs b/src/tests/Cocoar.Configuration.MultiTenant.Tests/TenantDiExclusionTests.cs new file mode 100644 index 0000000..dfb0b66 --- /dev/null +++ b/src/tests/Cocoar.Configuration.MultiTenant.Tests/TenantDiExclusionTests.cs @@ -0,0 +1,47 @@ +using Cocoar.Configuration.Core; +using Cocoar.Configuration.DI; +using Cocoar.Configuration.Providers; // FromStaticJson / FromStatic +using Cocoar.Configuration.Rules; +using Microsoft.Extensions.DependencyInjection; + +namespace Cocoar.Configuration.MultiTenant.Tests; + +/// +/// (P5) DI exclusion: a type whose every rule is .TenantScoped() has no global value and must NOT be +/// registered for injection (injecting it would freeze one tenant into a long-lived consumer — the captive +/// dependency bug ADR-005 §5 avoids). A type that also has a global base rule stays injectable. +/// +[Trait("Category", "MultiTenant")] +[Trait("Type", "Unit")] +public class TenantDiExclusionTests +{ + public sealed record GlobalBase { public string Region { get; init; } = ""; } + public sealed record TenantOnly { public string V { get; init; } = ""; } + + [Fact] + public void TenantOnlyType_IsExcludedFromGlobalDiPlan_GlobalBaseTypeStaysInjectable() + { + var services = new ServiceCollection(); + services.AddCocoarConfiguration(c => c.UseConfiguration(rules => new ConfigRule[] + { + // Has a global base AND a tenant overlay -> the global base value is injectable. + rules.For().FromStaticJson("""{ "Region": "base" }"""), + rules.For().FromStatic(a => new GlobalBase { Region = a.Tenant! }).TenantScoped(), + + // ONLY tenant-scoped -> no global value -> excluded from the DI plan. + rules.For().FromStatic(a => new TenantOnly { V = a.Tenant! }).TenantScoped(), + })); + + using var provider = services.BuildServiceProvider(); + using var scope = provider.CreateScope(); + var sp = scope.ServiceProvider; + + // The global-base type resolves to its global value (tenant overlay skipped in the global pipeline). + var globalBase = sp.GetService(); + Assert.NotNull(globalBase); + Assert.Equal("base", globalBase!.Region); + + // The purely tenant-scoped type is not injectable — obtain it via GetConfigForTenant(id) instead. + Assert.Null(sp.GetService()); + } +} diff --git a/src/tests/Cocoar.Configuration.MultiTenant.Tests/TenantFanOutTests.cs b/src/tests/Cocoar.Configuration.MultiTenant.Tests/TenantFanOutTests.cs new file mode 100644 index 0000000..345dda2 --- /dev/null +++ b/src/tests/Cocoar.Configuration.MultiTenant.Tests/TenantFanOutTests.cs @@ -0,0 +1,54 @@ +using System.Reactive.Subjects; +using Cocoar.Configuration.Core; +using Cocoar.Configuration.Providers; // FromObservable + +namespace Cocoar.Configuration.MultiTenant.Tests; + +/// +/// Fan-out: a change to the shared global base must reach already-initialized tenants. In the v1 model each +/// tenant pipeline runs the full flat rule list with its OWN provider instances and OWN change subscriptions, +/// so a live base source (file/observable/http) propagates to every initialized tenant automatically — no +/// cross-pipeline coordinator required. This test pins that property (see ADR-005 §6 and the (c) note). +/// +[Trait("Category", "MultiTenant")] +[Trait("Type", "Unit")] +public class TenantFanOutTests +{ + public sealed record Smtp + { + public string Host { get; init; } = ""; + public int Port { get; init; } + } + + [Fact] + public async Task GlobalBaseChange_FansOutToInitializedTenants_TenantOverrideSurvives() + { + using var sharedBase = new BehaviorSubject("""{ "Host": "smtp.global", "Port": 25 }"""); + + using var mgr = ConfigManager.Create(c => c + .UseConfiguration(rules => + [ + rules.For().FromObservable(sharedBase), + rules.For().FromObservable("""{ "Host": "smtp.tenant" }""").TenantScoped(), + ]) + .UseDebounce(25)); + + var tenants = (ITenantConfigurationAccessor)mgr; + await tenants.InitializeTenantAsync("acme"); + await tenants.InitializeTenantAsync("globex"); + + Assert.Equal(25, mgr.GetConfigForTenant("acme")!.Port); + Assert.Equal(25, mgr.GetConfigForTenant("globex")!.Port); + + // Global base change Port 25 -> 2525 fans out to BOTH initialized tenants. + sharedBase.OnNext("""{ "Host": "smtp.global", "Port": 2525 }"""); + + await TenantWait.UntilAsync(() => mgr.GetConfigForTenant("acme")?.Port == 2525, "acme sees base change"); + await TenantWait.UntilAsync(() => mgr.GetConfigForTenant("globex")?.Port == 2525, "globex sees base change"); + + // Tenant override survives the base change; the global pipeline also tracks it. + Assert.Equal("smtp.tenant", mgr.GetConfigForTenant("acme")!.Host); + Assert.Equal("smtp.global", mgr.GetConfig()!.Host); + Assert.Equal(2525, mgr.GetConfig()!.Port); + } +} diff --git a/src/tests/Cocoar.Configuration.MultiTenant.Tests/TenantFlagsTests.cs b/src/tests/Cocoar.Configuration.MultiTenant.Tests/TenantFlagsTests.cs new file mode 100644 index 0000000..550a8ed --- /dev/null +++ b/src/tests/Cocoar.Configuration.MultiTenant.Tests/TenantFlagsTests.cs @@ -0,0 +1,100 @@ +using Cocoar.Configuration.Core; +using Cocoar.Configuration.Flags; +using Cocoar.Configuration.Providers; // FromStaticJson / FromStatic + +namespace Cocoar.Configuration.MultiTenant.Tests; + +public sealed record BillingConfig { public bool PremiumBilling { get; init; } } + +// Source-generated: the generator emits the ctor taking IReactiveConfig + the Config property. +public partial class BillingFlags : IFeatureFlags +{ + public DateTimeOffset ExpiresAt => new(2099, 1, 1, 0, 0, 0, TimeSpan.Zero); + public FeatureFlag PremiumEnabled => () => Config.PremiumBilling; +} + +public sealed record PlanConfig +{ + public string Plan { get; init; } = "free"; + public int MaxSeats { get; init; } +} + +public partial class PlanEntitlements : IEntitlements +{ + public Entitlement CanExport => () => Config.Plan == "enterprise"; + public Entitlement MaxSeats => () => Config.MaxSeats; +} + +/// +/// (P3) per-tenant flags/entitlements through the real API: the SAME source-generated class constructed with +/// each tenant's own IReactiveConfig<T> (no generator change). Ports the POC's TenantFlagsPocTests. +/// +[Trait("Category", "MultiTenant")] +[Trait("Type", "Unit")] +public class TenantFlagsTests +{ + [Fact] + public async Task FeatureFlag_EvaluatesPerTenant() + { + using var mgr = ConfigManager.Create(c => c + .UseConfiguration(rules => + [ + rules.For().FromStaticJson("""{ "PremiumBilling": false }"""), + rules.For().FromStatic(a => new BillingConfig { PremiumBilling = a.Tenant == "A" }).TenantScoped(), + ]) + .UseFeatureFlags(flags => [flags.Register()]) + .UseDebounce(25)); + + var tenants = (ITenantConfigurationAccessor)mgr; + await tenants.InitializeTenantAsync("A"); + await tenants.InitializeTenantAsync("B"); + + var flagsA = mgr.GetFeatureFlagsForTenant("A"); + var flagsB = mgr.GetFeatureFlagsForTenant("B"); + + Assert.True(flagsA.PremiumEnabled()); // tenant A: premium on + Assert.False(flagsB.PremiumEnabled()); // tenant B: default off (inherited base) + Assert.False(mgr.GetFeatureFlags().PremiumEnabled()); // global: overlay skipped -> base + + // Per-(tenant, T) singleton caching; tenants never alias each other. + Assert.Same(flagsA, mgr.GetFeatureFlagsForTenant("A")); + Assert.NotSame(flagsA, flagsB); + } + + [Fact] + public async Task Entitlement_EvaluatesPerTenant() + { + using var mgr = ConfigManager.Create(c => c + .UseConfiguration(rules => + [ + rules.For().FromStaticJson("""{ "Plan": "free", "MaxSeats": 5 }"""), + rules.For().FromStatic(a => a.Tenant == "A" + ? new PlanConfig { Plan = "enterprise", MaxSeats = 500 } + : new PlanConfig { Plan = "free", MaxSeats = 5 }).TenantScoped(), + ]) + .UseEntitlements(e => [e.Register()]) + .UseDebounce(25)); + + var tenants = (ITenantConfigurationAccessor)mgr; + await tenants.InitializeTenantAsync("A"); + await tenants.InitializeTenantAsync("B"); + + var entA = mgr.GetEntitlementsForTenant("A"); + var entB = mgr.GetEntitlementsForTenant("B"); + + Assert.True(entA.CanExport()); // enterprise + Assert.False(entB.CanExport()); // free (inherited base) + Assert.Equal(500, entA.MaxSeats()); + Assert.Equal(5, entB.MaxSeats()); + } + + [Fact] + public void GetFeatureFlagsForTenant_BeforeInit_Throws() + { + using var mgr = ConfigManager.Create(c => c + .UseConfiguration(rules => [rules.For().FromStaticJson("""{ "PremiumBilling": false }""")]) + .UseFeatureFlags(flags => [flags.Register()])); + + Assert.Throws(() => mgr.GetFeatureFlagsForTenant("ghost")); + } +} diff --git a/src/tests/Cocoar.Configuration.MultiTenant.Tests/TenantLifecycleTests.cs b/src/tests/Cocoar.Configuration.MultiTenant.Tests/TenantLifecycleTests.cs new file mode 100644 index 0000000..4e099a1 --- /dev/null +++ b/src/tests/Cocoar.Configuration.MultiTenant.Tests/TenantLifecycleTests.cs @@ -0,0 +1,127 @@ +using System.Reactive.Subjects; +using Cocoar.Configuration.Core; +using Cocoar.Configuration.Providers; // FromObservable / FromStatic / FromStaticJson + +namespace Cocoar.Configuration.MultiTenant.Tests; + +/// +/// (e) tenant removal + dispose ordering: a removed tenant is forgotten (reads throw), can be rebuilt, and a +/// removed/idle tenant stops tracking the global base (its pipeline — engine, subscriptions, backplane — is +/// disposed). Disposing the manager tears down all tenants. Concurrent init/remove never deadlocks or corrupts. +/// +[Trait("Category", "MultiTenant")] +[Trait("Type", "Unit")] +public class TenantLifecycleTests +{ + public sealed record Geo { public string Region { get; init; } = ""; } + + public sealed record Smtp + { + public string Host { get; init; } = ""; + public int Port { get; init; } + } + + [Fact] + public async Task RemoveTenant_ForgetsTenant_ThenReInitRebuilds() + { + using var mgr = ConfigManager.Create(c => c + .UseConfiguration(rules => + [ + rules.For().FromStaticJson("""{ "Region": "base" }"""), + rules.For().FromStatic(a => new Geo { Region = a.Tenant! }).TenantScoped(), + ]) + .UseDebounce(25)); + + var tenants = (ITenantConfigurationAccessor)mgr; + + await tenants.InitializeTenantAsync("acme"); + Assert.Equal("acme", mgr.GetConfigForTenant("acme")!.Region); + + await tenants.RemoveTenantAsync("acme"); + Assert.False(tenants.IsTenantInitialized("acme")); + Assert.Throws(() => mgr.GetConfigForTenant("acme")); + + // Re-init builds a fresh pipeline. + await tenants.EnsureTenantInitializedAsync("acme"); + Assert.True(tenants.IsTenantInitialized("acme")); + Assert.Equal("acme", mgr.GetConfigForTenant("acme")!.Region); + } + + [Fact] + public async Task RemoveUnknownTenant_IsNoOp() + { + using var mgr = ConfigManager.Create(c => c + .UseConfiguration(rules => [rules.For().FromStaticJson("""{ "Region": "base" }""")])); + + await ((ITenantConfigurationAccessor)mgr).RemoveTenantAsync("ghost"); // must not throw + } + + [Fact] + public async Task RemovedTenant_PipelineIsDisposed() + { + using var sharedBase = new BehaviorSubject("""{ "Host": "smtp.global", "Port": 25 }"""); + + using var mgr = ConfigManager.Create(c => c + .UseConfiguration(rules => [rules.For().FromObservable(sharedBase)]) + .UseDebounce(25)); + + var tenants = (ITenantConfigurationAccessor)mgr; + await tenants.InitializeTenantAsync("acme"); + + var reactive = tenants.GetReactiveConfigForTenant("acme"); + Assert.Equal(25, reactive.CurrentValue.Port); + + await tenants.RemoveTenantAsync("acme"); + + // RemoveTenantAsync drains the in-flight recompute and disposes the pipeline (engine + subscriptions + + // backplane). A stale reactive handle held across removal now observes a disposed backplane — proving the + // teardown actually happened (vs. a leaked, still-live pipeline). + Assert.False(tenants.IsTenantInitialized("acme")); + Assert.Throws(() => _ = reactive.CurrentValue); + } + + [Fact] + public async Task DisposeManager_TearsDownTenants() + { + var mgr = ConfigManager.Create(c => c + .UseConfiguration(rules => [rules.For().FromStaticJson("""{ "Region": "base" }""")]) + .UseDebounce(25)); + + var tenants = (ITenantConfigurationAccessor)mgr; + await tenants.InitializeTenantAsync("acme"); + await tenants.InitializeTenantAsync("globex"); + + await mgr.DisposeAsync(); + + Assert.False(tenants.IsTenantInitialized("acme")); + Assert.False(tenants.IsTenantInitialized("globex")); + } + + [Fact] + public async Task ConcurrentInitAndRemove_NoDeadlockOrCorruption() + { + using var mgr = ConfigManager.Create(c => c + .UseConfiguration(rules => + [ + rules.For().FromStaticJson("""{ "Region": "base" }"""), + rules.For().FromStatic(a => new Geo { Region = a.Tenant! }).TenantScoped(), + ]) + .UseDebounce(25)); + + var tenants = (ITenantConfigurationAccessor)mgr; + + // Hammer init + remove across a handful of tenants concurrently; must not throw, deadlock, or leak state. + var work = new List(); + for (var i = 0; i < 8; i++) + { + var id = $"t{i % 3}"; + work.Add(Task.Run(() => tenants.EnsureTenantInitializedAsync(id))); + work.Add(Task.Run(() => tenants.RemoveTenantAsync(id))); + } + await Task.WhenAll(work); + + // The manager is still usable afterwards: a fresh init resolves correctly. + await tenants.EnsureTenantInitializedAsync("final"); + Assert.Equal("final", mgr.GetConfigForTenant("final")!.Region); + } +} diff --git a/src/tests/Cocoar.Configuration.MultiTenant.Tests/TenantLocalStorageTests.cs b/src/tests/Cocoar.Configuration.MultiTenant.Tests/TenantLocalStorageTests.cs new file mode 100644 index 0000000..2bf3589 --- /dev/null +++ b/src/tests/Cocoar.Configuration.MultiTenant.Tests/TenantLocalStorageTests.cs @@ -0,0 +1,76 @@ +using System.Collections.Concurrent; +using Cocoar.Configuration.Core; +using Cocoar.Configuration.LocalStorage; +using Cocoar.Configuration.Providers; // FromObservable / FromLocalStorage / IStorageBackend / GetLocalStorageForTenant + +namespace Cocoar.Configuration.MultiTenant.Tests; + +/// In-memory backend so each tenant gets an isolated, file-free overlay store. +internal sealed class InMemoryBackend : IStorageBackend +{ + private byte[]? _data; + + public Task ReadAsync(string key, CancellationToken ct = default) => Task.FromResult(_data); + + public Task WriteAsync(string key, byte[] data, CancellationToken ct = default) + { + _data = data; + return Task.CompletedTask; + } +} + +/// +/// (P4) per-tenant LocalStorage through the real API: each tenant's overlay uses its own backend (the factory +/// overload keys the store by accessor.Tenant), so a write to one tenant's overlay leaves the others untouched, +/// and provenance is computed per tenant. Ports the POC's TenantLocalStoragePocTests. +/// +[Trait("Category", "MultiTenant")] +[Trait("Type", "Unit")] +public class TenantLocalStorageTests +{ + public sealed record Smtp + { + public string Host { get; init; } = ""; + public int Port { get; init; } + } + + [Fact] + public async Task LocalStorageOverlay_IsPerTenant_WithDistinctBackends() + { + var backends = new ConcurrentDictionary(); + IStorageBackend BackendFor(string? tenant) => backends.GetOrAdd(tenant ?? "", _ => new InMemoryBackend()); + + using var mgr = ConfigManager.Create(c => c + .UseConfiguration(rules => + [ + rules.For().FromObservable("""{ "Host": "smtp.default.com", "Port": 25 }"""), + rules.For().FromLocalStorage((a, _) => BackendFor(a.Tenant)).TenantScoped(), + ]) + .UseDebounce(25)); + + var tenants = (ITenantConfigurationAccessor)mgr; + await tenants.InitializeTenantAsync("A"); + await tenants.InitializeTenantAsync("B"); + + // Tenant A writes Port=587 into ITS OWN overlay store. + var storageA = mgr.GetLocalStorageForTenant("A"); + await storageA.SetAsync(x => x.Port, 587); + await TenantWait.UntilAsync(() => mgr.GetConfigForTenant("A")?.Port == 587, "tenant A override applied"); + + // (1) A's effective value reflects the write; Host is inherited from the base (sparse overlay). + var configA = mgr.GetConfigForTenant("A")!; + Assert.Equal(587, configA.Port); + Assert.Equal("smtp.default.com", configA.Host); + + // (2) Tenant B is UNAFFECTED — its store is a distinct backend instance. + Assert.Equal(25, mgr.GetConfigForTenant("B")!.Port); + Assert.Null(await mgr.GetLocalStorageForTenant("B").ReadAsync()); + + // (3) Base-vs-effective provenance for A, computed over the tenant pipeline. + var entries = await storageA.DescribeAsync(); + var port = Assert.Single(entries, e => e.KeyPath == "Port"); + Assert.True(port.IsOverridden); + Assert.Equal(25, port.BaseValue!.Value.GetInt32()); + Assert.Equal(587, port.EffectiveValue!.Value.GetInt32()); + } +} diff --git a/src/tests/Cocoar.Configuration.MultiTenant.Tests/TenantReactiveTests.cs b/src/tests/Cocoar.Configuration.MultiTenant.Tests/TenantReactiveTests.cs new file mode 100644 index 0000000..5807132 --- /dev/null +++ b/src/tests/Cocoar.Configuration.MultiTenant.Tests/TenantReactiveTests.cs @@ -0,0 +1,81 @@ +using System.Reactive.Subjects; +using Cocoar.Configuration.Core; +using Cocoar.Configuration.Providers; // FromObservable / FromStaticJson / FromStatic + +namespace Cocoar.Configuration.MultiTenant.Tests; + +/// +/// (d) per-tenant reactive: GetReactiveConfigForTenant<T> tracks the tenant's effective value +/// (single type and same-scope tuple), and the global reactive path is unchanged. +/// +[Trait("Category", "MultiTenant")] +[Trait("Type", "Unit")] +public class TenantReactiveTests +{ + public sealed record Smtp + { + public string Host { get; init; } = ""; + public int Port { get; init; } + } + + public sealed record A { public string V { get; init; } = ""; } + public sealed record B { public int N { get; init; } } + + [Fact] + public async Task GetReactiveConfigForTenant_EmitsTenantValue_AndUpdatesOnBaseChange() + { + using var sharedBase = new BehaviorSubject("""{ "Host": "smtp.global", "Port": 25 }"""); + + using var mgr = ConfigManager.Create(c => c + .UseConfiguration(rules => + [ + rules.For().FromObservable(sharedBase), + rules.For().FromObservable("""{ "Host": "smtp.tenant" }""").TenantScoped(), + ]) + .UseDebounce(25)); + + var tenants = (ITenantConfigurationAccessor)mgr; + await tenants.InitializeTenantAsync("acme"); + + var reactive = tenants.GetReactiveConfigForTenant("acme"); + Assert.Equal("smtp.tenant", reactive.CurrentValue.Host); // overlay wins + Assert.Equal(25, reactive.CurrentValue.Port); // inherited from base + + var received = new List(); + using var sub = reactive.Subscribe(received.Add); + + sharedBase.OnNext("""{ "Host": "smtp.global", "Port": 2525 }"""); + await TenantWait.UntilAsync(() => reactive.CurrentValue.Port == 2525, "tenant reactive sees base change"); + + Assert.Equal("smtp.tenant", reactive.CurrentValue.Host); // override survived the base change + Assert.Contains(received, v => v.Port == 2525); // subscriber was notified + } + + [Fact] + public async Task GetReactiveConfigForTenant_Tuple_ReadsTenantValues_GlobalReadsBase() + { + using var mgr = ConfigManager.Create(c => c + .UseConfiguration(rules => + [ + rules.For().FromStaticJson("""{ "V": "base" }"""), + rules.For().FromStatic(x => new A { V = x.Tenant! }).TenantScoped(), + rules.For().FromStaticJson("""{ "N": 1 }"""), + rules.For().FromStatic(_ => new B { N = 9 }).TenantScoped(), + ]) + .UseDebounce(25)); + + var tenants = (ITenantConfigurationAccessor)mgr; + await tenants.InitializeTenantAsync("acme"); + + // Tenant tuple reactive reads the tenant's own accessor + backplane (loosened ReactiveTupleConfig). + var tenantReactive = tenants.GetReactiveConfigForTenant<(A, B)>("acme"); + var (a, b) = tenantReactive.CurrentValue; + Assert.Equal("acme", a.V); + Assert.Equal(9, b.N); + + // Global tuple reactive is unchanged: overlays skipped -> base values. + var globalReactive = mgr.GetReactiveConfig<(A, B)>(); + Assert.Equal("base", globalReactive.CurrentValue.Item1.V); + Assert.Equal(1, globalReactive.CurrentValue.Item2.N); + } +} diff --git a/src/tests/Cocoar.Configuration.MultiTenant.Tests/TenantSecretsTests.cs b/src/tests/Cocoar.Configuration.MultiTenant.Tests/TenantSecretsTests.cs new file mode 100644 index 0000000..8ac405f --- /dev/null +++ b/src/tests/Cocoar.Configuration.MultiTenant.Tests/TenantSecretsTests.cs @@ -0,0 +1,129 @@ +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; +using System.Text.Json; +using Cocoar.Configuration.Core; +using Cocoar.Configuration.DI; +using Cocoar.Configuration.Providers; // FromStaticJson / FromLocalStorage / IStorageBackend / GetLocalStorageForTenant +using Cocoar.Configuration.Secrets; +using Cocoar.Configuration.Secrets.SecretTypes; +using Cocoar.Configuration.X509Encryption; +using Microsoft.Extensions.DependencyInjection; + +namespace Cocoar.Configuration.MultiTenant.Tests; + +public sealed class VaultConfig +{ + public Secret? ApiKey { get; set; } +} + +/// +/// (Secrets) per-tenant secrets on the shared-base model with NO multi-tenancy-specific secrets code: the +/// existing multi-kid folder mode (kid = tenant subdirectory) routes decryption, and each tenant's overlay +/// carries an envelope tagged with its own kid. A tenant decrypts its own secret via its own cert; it cannot +/// decrypt another tenant's. Ports the intent of the POC's TenantSecretsPocTests to the real engine. +/// +[Trait("Category", "MultiTenant")] +[Trait("Type", "Unit")] +public sealed class TenantSecretsTests : IDisposable +{ + private readonly string _certsRoot = Path.Combine(Path.GetTempPath(), "cocoar-mt-secrets-" + Guid.NewGuid().ToString("N")); + + [Fact] + public async Task PerTenantKid_EachTenantDecryptsItsOwnSecret() + { + // certsRoot/{kid}/cert.pfx — kid = tenant id (the documented multi-tenant folder layout). + using var certA = GenerateTenantCert("tenantA"); + using var certB = GenerateTenantCert("tenantB"); + + var backends = new Dictionary(); + IStorageBackend BackendFor(string? tenant) + { + if (!backends.TryGetValue(tenant ?? "", out var b)) backends[tenant ?? ""] = b = new InMemoryBackend(); + return b; + } + + var services = new ServiceCollection(); + services.AddCocoarConfiguration(c => c + .UseConfiguration(rules => + [ + rules.For().FromStaticJson("{}"), + rules.For().FromLocalStorage((a, _) => BackendFor(a.Tenant)).TenantScoped(), + ]) + .UseSecretsSetup(secrets => secrets.UseCertificatesFromFolder(_certsRoot)) + .UseDebounce(25)); + + using var provider = services.BuildServiceProvider(); + var mgr = provider.GetRequiredService(); + var tenants = (ITenantConfigurationAccessor)mgr; + + await tenants.InitializeTenantAsync("tenantA"); + await tenants.InitializeTenantAsync("tenantB"); + + // Tenant A: encrypt "secret-A" to A's public key (kid=tenantA), write to A's overlay -> A decrypts. + await mgr.GetLocalStorageForTenant("tenantA") + .SetSecretAsync(x => x.ApiKey!, EncryptForKid(certA.GetRSAPublicKey()!, "tenantA", "secret-A")); + await TenantWait.UntilAsync(() => mgr.GetConfigForTenant("tenantA")?.ApiKey is not null, "tenant A secret applied"); + using (var leaseA = mgr.GetConfigForTenant("tenantA")!.ApiKey!.Open()) + Assert.Equal("secret-A", leaseA.Value); + + // Tenant B: its own kid + cert. + await mgr.GetLocalStorageForTenant("tenantB") + .SetSecretAsync(x => x.ApiKey!, EncryptForKid(certB.GetRSAPublicKey()!, "tenantB", "secret-B")); + await TenantWait.UntilAsync(() => mgr.GetConfigForTenant("tenantB")?.ApiKey is not null, "tenant B secret applied"); + using (var leaseB = mgr.GetConfigForTenant("tenantB")!.ApiKey!.Open()) + Assert.Equal("secret-B", leaseB.Value); + } + + private X509Certificate2 GenerateTenantCert(string kid) + { + var kidFolder = Path.Combine(_certsRoot, kid); + Directory.CreateDirectory(kidFolder); + var pfxPath = Path.Combine(kidFolder, "cert.pfx"); + return X509CertificateGenerator.GenerateAndSavePfx(pfxPath, password: null, $"CN=Cocoar {kid}", overwrite: true); + } + + public void Dispose() + { + try { if (Directory.Exists(_certsRoot)) Directory.Delete(_certsRoot, recursive: true); } catch { /* best effort */ } + } + + // ---- client-side envelope encryption (what a browser/producer does with only the public key) ---- + + private static SecretEnvelope EncryptForKid(RSA rsaPublic, string kid, string value) + { + var plaintext = JsonSerializer.SerializeToUtf8Bytes(value); + + Span dek = stackalloc byte[32]; + RandomNumberGenerator.Fill(dek); + try + { + var iv = new byte[12]; + RandomNumberGenerator.Fill(iv); + var ct = new byte[plaintext.Length]; + var tag = new byte[16]; + + using (var aes = new AesGcm(dek, tag.Length)) + { + aes.Encrypt(iv, plaintext, ct, tag, associatedData: null); + } + + var wk = rsaPublic.Encrypt(dek.ToArray(), RSAEncryptionPadding.OaepSHA256); + + return new SecretEnvelope + { + Kid = kid, + Wk = ToBase64Url(wk), + Iv = ToBase64Url(iv), + Ct = ToBase64Url(ct), + Tag = ToBase64Url(tag), + }; + } + finally + { + CryptographicOperations.ZeroMemory(dek); + } + } + + private static string ToBase64Url(byte[] bytes) + => Convert.ToBase64String(bytes).Replace('+', '-').Replace('/', '_').TrimEnd('='); +} diff --git a/src/tests/Cocoar.Configuration.MultiTenant.Tests/TenantWait.cs b/src/tests/Cocoar.Configuration.MultiTenant.Tests/TenantWait.cs new file mode 100644 index 0000000..893c23e --- /dev/null +++ b/src/tests/Cocoar.Configuration.MultiTenant.Tests/TenantWait.cs @@ -0,0 +1,28 @@ +namespace Cocoar.Configuration.MultiTenant.Tests; + +/// Minimal active-wait helper so this project stays self-contained (no cross-test-project reference). +internal static class TenantWait +{ + public static async Task UntilAsync(Func condition, string description, int timeoutMs = 15000, int pollMs = 25) + { + var stopwatch = System.Diagnostics.Stopwatch.StartNew(); + while (stopwatch.ElapsedMilliseconds < timeoutMs) + { + try + { + if (condition()) + { + return; + } + } + catch + { + // condition touched not-yet-ready state — treat as "not met yet" + } + + await Task.Delay(pollMs); + } + + throw new TimeoutException($"Timeout waiting for {description} after {timeoutMs}ms"); + } +} diff --git a/website/.vitepress/config.ts b/website/.vitepress/config.ts index 03cfe83..d373de3 100644 --- a/website/.vitepress/config.ts +++ b/website/.vitepress/config.ts @@ -98,6 +98,12 @@ export default defineConfig({ { text: 'Expiry & Health', link: '/guide/flags/expiry-health' }, ], }, + { + text: 'Multi-Tenancy', + items: [ + { text: 'Overview ', link: '/guide/multi-tenancy/overview' }, + ], + }, { text: 'Secrets', items: [ diff --git a/website/guide/multi-tenancy/overview.md b/website/guide/multi-tenancy/overview.md new file mode 100644 index 0000000..7762c52 --- /dev/null +++ b/website/guide/multi-tenancy/overview.md @@ -0,0 +1,112 @@ +# Multi-Tenancy + +Multi-tenant applications need the **same configuration type to resolve to different values per tenant** — a global default for everything, with each tenant overriding only the keys it sets and inheriting the rest. + +Cocoar.Configuration models this as **per-tenant pipeline bundles layered on a shared global base** (see [ADR-005](https://github.com/cocoar-dev/cocoar.configuration/blob/develop/docs/adr/ADR-005-multi-tenant-configuration.md)). You author **one flat rule list** and mark the per-tenant rules with `.TenantScoped()`; the tenant id flows in through the configuration accessor. There is no second authoring surface and no provider becomes "tenant-aware". + +::: tip When do I need this? +Only when one process serves many tenants and the **same type** must differ per tenant at runtime, with tenants added/removed dynamically. A single-tenant app needs none of this — the global pipeline is unchanged. +::: + +## The two primitives + +- **`IConfigurationAccessor.Tenant`** — `null` in the global (tenant-agnostic) pipeline, the tenant id inside a tenant pipeline. Tenant-varying rule factories interpolate it. +- **`.TenantScoped()`** on a rule — the rule runs **only** for a tenant and is skipped in the global pipeline. It is shorthand for `.When(a => !string.IsNullOrWhiteSpace(a.Tenant))`. + +```csharp +var manager = ConfigManager.Create(c => c.UseConfiguration(rules => +[ + // Global base — applies to everything, injectable as usual: + rules.For().FromStaticJson(smtpDefaults), + + // Per-tenant overlay — wins per key, inherits the rest. The id flows via the accessor: + rules.For().FromFile(a => $"tenants/{a.Tenant}/smtp.json").TenantScoped(), +])); +``` + +The effective value for a tenant is `[global rules] ++ [tenant-scoped rules]`, run through the **same** recompute/merge pipeline as any config — so transforms, required-rule rollback and dependency ordering all behave identically. Placing a global rule **after** the tenant overlay makes it a non-negotiable platform ceiling (it wins over the tenant) — no special tier, just list position. + +## Lifecycle + +The host owns the tenant list. A tenant's configuration is materialized **on demand** and async is confined to that init moment — reads stay synchronous, exactly like the global config. + +```csharp +var tenants = (ITenantConfigurationAccessor)manager; // ConfigManager implements it + +await tenants.InitializeTenantAsync("acme"); // build the tenant pipeline (at tenant creation) +await tenants.EnsureTenantInitializedAsync("acme"); // idempotent warmup (e.g. request-start middleware) +bool ready = tenants.IsTenantInitialized("acme"); +await tenants.RemoveTenantAsync("acme"); // dispose the tenant bundle (at tenant removal) +``` + +`InitializeTenantAsync` is idempotent and safe under concurrency — a tenant is built exactly once. + +## Consuming a tenant's configuration + +Tenant-scoped values are obtained by **passing the tenant id**, never by DI injection: + +```csharp +var smtp = manager.GetConfigForTenant("acme"); // sync read +var live = manager.GetReactiveConfigForTenant("acme"); // IReactiveConfig for this tenant +var flags = manager.GetFeatureFlagsForTenant("acme"); +var ents = manager.GetEntitlementsForTenant("acme"); +var store = manager.GetLocalStorageForTenant("acme"); // per-tenant write facade +``` + +### Not DI-injectable — by design + +A type whose **every** rule is `.TenantScoped()` has no global value. Injecting it into a long-lived (singleton) consumer would be a captive-dependency bug — it would freeze one tenant forever, since the container cannot know the runtime tenant. The DI planner therefore **excludes** purely tenant-scoped types from the global plan. A type that *also* has a global base rule stays injectable (its base value is a valid global config). Consuming services inject the `ConfigManager` / `ITenantConfigurationAccessor` and call `…ForTenant(currentTenant)`. + +## Feature flags & entitlements per tenant + +The same source-generated flag/entitlement class is constructed with the **tenant's** `IReactiveConfig`, so it evaluates against that tenant's effective config — **no source-generator change**: + +```csharp +public partial class BillingFlags : IFeatureFlags +{ + public FeatureFlag PremiumEnabled => () => Config.PremiumBilling; +} + +bool premium = manager.GetFeatureFlagsForTenant("acme").PremiumEnabled(); +``` + +In ASP.NET Core, map the tenant-dimensioned REST endpoints (a `{tenant}` route segment; the handler warms the tenant up and evaluates per tenant): + +```csharp +app.MapTenantFeatureFlagEndpoints(); // GET /tenants/{tenant}/flags/{FlagClass}/{FlagName} +app.MapTenantEntitlementEndpoints(); // GET /tenants/{tenant}/entitlements/{Class}/{Name} +``` + +## Per-tenant LocalStorage + +Give each tenant its own backend via the factory overload (the store is keyed by `accessor.Tenant`), and write through the per-tenant facade: + +```csharp +rules.For().FromLocalStorage((a, _) => BackendFor(a.Tenant)).TenantScoped() + +await manager.GetLocalStorageForTenant("acme").SetAsync(x => x.Port, 587); +``` + +A write triggers only that tenant's recompute; other tenants are untouched. Provenance (`DescribeAsync`) is computed over the tenant's own layers. + +## Per-tenant secrets + +Per-tenant secrets reuse the existing **multi-kid certificate folder** — `kid = tenant`. Lay certificates out as `certsRoot/{tenant}/cert.pfx` and each tenant's overlay carries an envelope tagged with its own kid: + +```csharp +c.UseSecretsSetup(secrets => secrets.UseCertificatesFromFolder(certsRoot)); + +using var lease = manager.GetConfigForTenant("acme").ApiKey!.Open(); // decrypts via certsRoot/acme +``` + +A tenant decrypts its own secret with its own certificate; it cannot decrypt another tenant's. + +## Fan-out: global changes reach tenants automatically + +Each tenant pipeline runs the full rule list with its **own** provider subscriptions, so a change to a live global base source (file / observable / HTTP) propagates to every initialized tenant on its own debounced recompute and re-emits on that tenant's `IReactiveConfig`. A tenant that masks the changed key with its own override does not emit. No coordinator to configure; consistency is **per-tenant eventual** (a global change lands tenant-by-tenant as each rebuild finishes). + +## Limits in this version + +- **Mixed-scope tuples** — `IReactiveConfig<(Global, TenantScoped)>` / `IFeatureFlags<(Global, TenantScoped)>` are **not supported** (they would show transient skew during fan-out). Use same-scope tuples. +- **Resource use** scales linearly with initialized tenants × base rules (each tenant re-runs the base). Acceptable for a host-bounded active-tenant set; a shared seed-from-global optimization is a future, API-compatible change. +- **Eviction** is explicit (`RemoveTenantAsync`) only — no idle eviction. From 6923123c0e4d725a7836858af8a7b67a031118bb Mon Sep 17 00:00:00 2001 From: Bernhard Windisch Date: Sat, 30 May 2026 21:19:58 +0200 Subject: [PATCH 03/18] feat: DI-aware service-backed configuration (ADR-006) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A two-layer model so config providers can use DI-managed services without giving up the no-DI core. Layer 1 (UseConfiguration) stays eager and DI-free; Layer 2 (UseServiceBackedConfiguration, in the DI package) holds rules whose provider factories receive the application root IServiceProvider. - FromStorage((sp,a)=>IStorageBackend), FromHttp((sp,a)=>HttpClient), FromService(s=>config) — providers can use IHttpClientFactory / Marten / EF - Activated on host start via IHostedLifecycleService as a semaphore-guarded RecomputeNowAsync (a recompute, never a rebuild) so every live IReactiveConfig view receives the Layer-2 value; fans out to initialized tenants - Type-safe seam: (sp,a) overloads target the public ServiceBackedProviderBuilder carrying a fail-loud ServiceBackedRuleContext, so misuse in Layer 1 is a compile error; third-party provider packages can author their own (sp,a) overloads - Fluent-order-proof ActivationGate in RuleManager.ShouldSkip (mirrors .TenantScoped()) Includes the adversarial-review hardening (per-rule activation, provider-key isolation, per-fetch client acquisition) and the ServiceBackedConfig example. --- docs/adr/ADR-006-di-aware-configuration.md | 239 ++++++++++++++++++ .../Tenant/ITenantContext.cs | 13 + .../Tenant/ITenantReactiveConfig.cs | 17 ++ .../Tenant/TenantReactiveConfig.cs | 39 +++ .../Tenant/TenantReactiveConfigExtensions.cs | 54 ++++ .../Cocoar.Configuration.DI.csproj | 2 + .../CocoarConfigurationExtensions.cs | 7 +- .../ServiceBackedConfigurationActivator.cs | 40 +++ .../ServiceBackedConfigurationCoordinator.cs | 107 ++++++++ .../ServiceBackedConfigurationExtensions.cs | 88 +++++++ .../ServiceBackedRulesExtensions.cs | 51 ++++ .../ServiceBacked/ServiceProviderHolder.cs | 45 ++++ src/Cocoar.Configuration.Http/HttpProvider.cs | 54 +++- .../HttpProviderOptions.cs | 34 ++- .../RulesExtensions.cs | 48 ++++ .../SseObservable.cs | 6 +- src/Cocoar.Configuration.slnx | 2 + .../Core/ConfigManager.cs | 64 +++++ .../Core/ConfigManagerBuilder.cs | 36 +++ .../Core/ConfigurationEngine.cs | 26 ++ .../Core/ServiceBackedRuleContext.cs | 47 ++++ .../Fluent/ProviderRuleBuilder.cs | 3 +- .../Fluent/RuleBuilderBase.cs | 22 ++ .../Fluent/ServiceBackedProviderBuilder.cs | 117 +++++++++ .../Fluent/ServiceBackedRulesBuilder.cs | 21 ++ .../Rules/ConfigRuleOptions.cs | 3 +- src/Cocoar.Configuration/Rules/RuleManager.cs | 8 + src/Directory.Packages.props | 3 + src/Examples/ServiceBackedConfig/Program.cs | 95 +++++++ .../ServiceBackedConfig.csproj | 17 ++ .../TenantReactiveConfigTests.cs | 93 +++++++ ...r.Configuration.ServiceBacked.Tests.csproj | 36 +++ .../ServiceBackedActivationTests.cs | 116 +++++++++ .../ServiceBackedFromServiceTests.cs | 73 ++++++ .../ServiceBackedHttpTests.cs | 162 ++++++++++++ .../ServiceBackedReactiveTests.cs | 58 +++++ .../ServiceBackedReviewRegressionTests.cs | 173 +++++++++++++ .../ServiceBackedStorageTests.cs | 94 +++++++ .../TestSupport.cs | 162 ++++++++++++ .../ThirdPartyServiceBackedProviderTests.cs | 132 ++++++++++ website/.vitepress/config.ts | 1 + website/guide/di/service-backed.md | 165 ++++++++++++ website/guide/multi-tenancy/overview.md | 29 +++ website/guide/providers/custom.md | 69 +++++ 44 files changed, 2651 insertions(+), 20 deletions(-) create mode 100644 docs/adr/ADR-006-di-aware-configuration.md create mode 100644 src/Cocoar.Configuration.AspNetCore/Tenant/ITenantContext.cs create mode 100644 src/Cocoar.Configuration.AspNetCore/Tenant/ITenantReactiveConfig.cs create mode 100644 src/Cocoar.Configuration.AspNetCore/Tenant/TenantReactiveConfig.cs create mode 100644 src/Cocoar.Configuration.AspNetCore/Tenant/TenantReactiveConfigExtensions.cs create mode 100644 src/Cocoar.Configuration.DI/ServiceBacked/ServiceBackedConfigurationActivator.cs create mode 100644 src/Cocoar.Configuration.DI/ServiceBacked/ServiceBackedConfigurationCoordinator.cs create mode 100644 src/Cocoar.Configuration.DI/ServiceBacked/ServiceBackedConfigurationExtensions.cs create mode 100644 src/Cocoar.Configuration.DI/ServiceBacked/ServiceBackedRulesExtensions.cs create mode 100644 src/Cocoar.Configuration.DI/ServiceBacked/ServiceProviderHolder.cs create mode 100644 src/Cocoar.Configuration/Core/ServiceBackedRuleContext.cs create mode 100644 src/Cocoar.Configuration/Fluent/ServiceBackedProviderBuilder.cs create mode 100644 src/Cocoar.Configuration/Fluent/ServiceBackedRulesBuilder.cs create mode 100644 src/Examples/ServiceBackedConfig/Program.cs create mode 100644 src/Examples/ServiceBackedConfig/ServiceBackedConfig.csproj create mode 100644 src/tests/Cocoar.Configuration.AspNetCore.Tests/TenantReactiveConfigTests.cs create mode 100644 src/tests/Cocoar.Configuration.ServiceBacked.Tests/Cocoar.Configuration.ServiceBacked.Tests.csproj create mode 100644 src/tests/Cocoar.Configuration.ServiceBacked.Tests/ServiceBackedActivationTests.cs create mode 100644 src/tests/Cocoar.Configuration.ServiceBacked.Tests/ServiceBackedFromServiceTests.cs create mode 100644 src/tests/Cocoar.Configuration.ServiceBacked.Tests/ServiceBackedHttpTests.cs create mode 100644 src/tests/Cocoar.Configuration.ServiceBacked.Tests/ServiceBackedReactiveTests.cs create mode 100644 src/tests/Cocoar.Configuration.ServiceBacked.Tests/ServiceBackedReviewRegressionTests.cs create mode 100644 src/tests/Cocoar.Configuration.ServiceBacked.Tests/ServiceBackedStorageTests.cs create mode 100644 src/tests/Cocoar.Configuration.ServiceBacked.Tests/TestSupport.cs create mode 100644 src/tests/Cocoar.Configuration.ServiceBacked.Tests/ThirdPartyServiceBackedProviderTests.cs create mode 100644 website/guide/di/service-backed.md diff --git a/docs/adr/ADR-006-di-aware-configuration.md b/docs/adr/ADR-006-di-aware-configuration.md new file mode 100644 index 0000000..e0c34c2 --- /dev/null +++ b/docs/adr/ADR-006-di-aware-configuration.md @@ -0,0 +1,239 @@ +# ADR-006: DI-aware Configuration (Two-Layer Model) + +**Status:** Accepted — implemented on `feature/multitenant` +**Date:** 2026-05-30 +**Decision Makers:** Core Team +**Type:** Feature / Architecture +**Related:** ADR-005 (multi-tenancy), the "No-DI core" principle (CLAUDE.md), Microsoft `IConfiguration`/`IOptions`, the HTTP/LocalStorage/Marten provider discussion + +> **Implementation note (delivered).** Shipped as `UseServiceBackedConfiguration` (Layer 2) + `FromStorage((sp,a)=>IStorageBackend)` (DI package) + `FromHttp((sp,a)=>HttpClient)` (Http package), activated by `ServiceBackedConfigurationActivator : IHostedLifecycleService` and the manual `IServiceProvider.ActivateServiceBackedConfigurationAsync()`. The sp-gate is a dedicated, non-clobberable `ConfigRuleOptions.ActivationGate` enforced in `RuleManager.ShouldSkip` (mirrors the `.TenantScoped()` marker — fluent-order-proof). Activation wiring lives in the DI **instance** overload `AddCocoarConfiguration(IServiceCollection, ConfigManager)` — the single point all entry paths (DI, AspNetCore, manual) funnel through. + +> The `(sp,a)` overloads are **type-scoped, not ambient**: `UseServiceBackedConfiguration(rules => …)` hands each `rules.For()` a public `ServiceBackedProviderBuilder : TypedProviderBuilder` carrying a public `ServiceBackedRuleContext` (`IsActive` + `ServiceProvider`). `FromStorage`/`FromHttp((sp,a)=>…)` are extensions on *that* type, so using them in Layer-1 `UseConfiguration` is a **compile error**, not a runtime throw. The seam is **public**: a third-party provider package authors its own `FromX((sp,a)=>…)` extension on `ServiceBackedProviderBuilder` (read `Context.ServiceProvider`, gate with the public `WithActivationGate(_ => Context.IsActive)`) and exposes a slot for the resolved artifact on its provider options. The provider class (`ConfigurationProvider<,>`) stays DI-free. Whether a provider is service-backable is the provider author's choice. §11 (scoped `ITenantReactiveConfig` + `ITenantContext`) shipped in `Cocoar.Configuration.AspNetCore`. Covered by `Cocoar.Configuration.ServiceBacked.Tests` + AspNetCore tenant-adapter tests. See "Open questions" below for the resolved decisions. + +--- + +## Context + +### The No-DI core (which we keep) +`Cocoar.Configuration` (core) has **zero dependency on `Microsoft.Extensions.DependencyInjection`** — only `Microsoft.Extensions.Logging.Abstractions` + first-party packages. This is load-bearing, not decorative: +- **Test ergonomics** — the bulk of the suite uses `ConfigManager.Create(...)` directly, no `ServiceProvider`. +- **Embedding moat** — a *library* can use Cocoar internally without forcing a DI container on its consumers. +- **CLI / workers / AOT / alt-containers** — `Cocoar.Configuration.Secrets.Cli` is a real no-DI consumer; Autofac/Lamar/DryIoc shops stay supported via the thin `.DI` adapter. + +**We do not delete or weaken the No-DI core.** This ADR adds DI capability *on top*, as an opt-in satellite. + +### The limitation this ADR removes +`AddCocoarConfiguration(Action)` today does: + +```csharp +var configManager = ConfigManager.Create(configure); // builds AND initializes EAGERLY, here +services.AddCocoarConfiguration(configManager); // registers the already-built INSTANCE (not a sp => factory) +``` + +`ConfigManager.Create` runs `Configure` **and** `Initialize` synchronously — instantiating every provider and running the initial recompute **before `BuildServiceProvider()` ever runs.** Consequences: + +- Config providers are built **pre-container** → they **cannot resolve services from the app container.** +- Evidence: the HTTP provider does `new HttpClient()` (`HttpProvider.cs`); it cannot use `IHttpClientFactory`. The only DI seam (`IProviderServiceRegistration`) is **registration-time and one-way** ("Called once during DI setup — not on every recompute") — providers register services *into* DI, they cannot resolve *from* it at recompute time. +- The most important enterprise providers therefore can't be done cleanly: **DB-backed config** (Marten `IDocumentStore`, EF `IDbContextFactory`) and **HTTP via `IHttpClientFactory`**. + +### The hard logical boundary (true in every framework) +Config that needs a DI service *to load* cannot be used to *bootstrap the DI container* — that is circular. So **pre-container config must come from dependency-free sources** (file, env, command-line, static). This is not a Cocoar limitation; Microsoft has the same boundary. + +### How Microsoft solves it (the pattern we follow) +A **two-layer** architecture: + +| Layer | When | DI? | +|---|---|---| +| `IConfiguration` (raw key-values, sources) | eager, pre-container | **no** — dumb providers; a provider that needs a dependency (Key Vault credential, EF context) is **hand-fed** it / news its own | +| `IOptions` / `IOptionsMonitor` (typed binding + post-processing) | **lazy, resolved from the container** | **yes** — `IConfigureOptions` are DI services that can inject dependencies | + +Cocoar currently **fuses** both layers (providers + typed binding + reactive, all eager in the `ConfigManager`). That is more powerful in some ways (layering, transforms, reactive in one model) but it inherits the eager-source limitation **without** Microsoft's lazy `IOptions` escape hatch. This ADR adds that lazy layer — in Cocoar's own ordered-layer idiom. + +> For the Marten/DB-per-tenant case, Cocoar with this layer would actually **exceed** Microsoft's built-ins: Microsoft's EF config provider news up its *own* `DbContext`; Cocoar would use the app's real, DI-managed `IDocumentStore`, tenant-scoped. + +--- + +## Decision + +Introduce a **two-layer configuration model**. The core stays DI-free; the DI integration is a **satellite extension on the DI package**, exactly like `UseSecretsSetup()` / `UseFeatureFlags()`. + +### 1. Two authoring surfaces + +```csharp +services.AddCocoarConfiguration(c => c + // Layer 1 — eager, no IServiceProvider, available pre-container. Wires the DI plan + bootstrap config. + .UseConfiguration(rule => + [ + rule.For().FromFile("appsettings.json"), // bootstrap log level (eager) + rule.For().FromFile(a => $"tenants/{a.Tenant}/db.json").TenantScoped(), // tenant, no DI + ]) + // Layer 2 — extension method FROM the DI package; rules whose factories receive the IServiceProvider. + .UseServiceBackedConfiguration(rule => + [ + rule.For().FromHttp((sp, a) => + sp.GetRequiredService().CreateClient("cocoar-config"), "logging.json"), + rule.For().FromStorage((sp, a) => + new MartenConfigBackend(sp.GetRequiredService(), a.Tenant)).TenantScoped(), + ])); +``` + +- `UseConfiguration` — **Layer 1**, core, unchanged. No `sp`. +- `UseServiceBackedConfiguration` — **Layer 2**, **defined in `Cocoar.Configuration.DI`** as an extension on `ConfigManagerBuilder`. Its rules' provider factories receive the `IServiceProvider`. + +`IServiceProvider` never appears in the core public surface. + +### 2. Mechanism — holder + per-rule `sp`-gate + activation hosted service + +Three pieces, **all in the DI package**: + +1. **`ServiceProviderHolder`** (DI-package singleton): `null` until the container is built; afterward holds the **root** `IServiceProvider`. +2. **`sp`-using factory overloads** (`FromStorage((sp,a)=>…)`, `FromHttp` with `IHttpClientFactory`, …): each wraps a core provider-options factory `accessor => userFactory(holder.ServiceProvider!, accessor)` **and** composes a gate `.When(_ => holder.HasServiceProvider)`. The gate reuses the `ShouldSkip` machinery hardened in ADR-005 (a rule that skips while its precondition is absent, contributing nothing). +3. **An activation `IHostedService`** (registered by the DI package's `AddCocoarConfiguration`, where the `IServiceCollection` is available; it is container-constructed so it receives `sp`): on host start it sets `holder.ServiceProvider = sp` and triggers a **recompute from the Layer-2 start index**. + +**Core touch is minimal:** reuse `ShouldSkip` (the `sp`-gate is expressed via the existing `When` predicate) and the already-internal `ScheduleRecompute(startIndex)` + `RestorePrefixContributions`. The only likely new core seam is a small **internal hook to append satellite-supplied rules** to the builder (consistent with how satellites already extend it). `InternalsVisibleTo("Cocoar.Configuration.DI")` already exists, so the DI package can drive the post-container recompute. + +### 3. Lifecycle — two-phase for the global pipeline, single-phase for tenants + +- **Global pipeline:** Layer 1 runs **eager** at registration (for the DI plan + bootstrap config). Layer-2 rules are **dormant** (`sp`-gated) until host start; the hosted service then sets the holder and triggers `ScheduleRecompute(layer2Index)` → Layer 2 activates, merges on top, reactive subscribers emit. +- **Tenant pipelines:** always built at runtime (`InitializeTenantAsync`, post-container, `sp` already present) → a **single** recompute runs Layer 1 + Layer 2 together; the `sp`-gate is automatically satisfied. The two-phase split is a *global-pipeline* concern only. + +### 4. Precedence and gating are separable (key clarification) + +"Layer 2" bundles two **independent** properties — keep them separate in the implementation: + +- **Precedence** = position in the combined list. The Layer-2 bucket sits *after* Layer 1 → Layer 2 wins per key. +- **Gating** = **per rule**, and only for rules whose factory actually uses `sp`. A non-`sp` rule placed in Layer 2 is **not** gated → it runs eagerly **and** gains the later precedence. + +Consequence — "a non-DI rule must beat a DI-backed rule" needs **no duplication**: declare the non-DI rule once, in Layer 2, *after* the DI-backed rule. It runs eagerly (no `sp`) and wins by position. + +**Decision:** gate **per-`sp`-usage** (recommended), not per-bucket. Per-bucket is a simpler mental model but needlessly defers non-`sp` rules placed in Layer 2. + +### 5. Tenancy is orthogonal to the layer + +Two independent axes; the layer is chosen by `sp`-need, **not** by tenancy: + +| | no `sp` (Layer 1) | needs `sp` (Layer 2) | +|---|---|---| +| **global** | `FromFile("app.json")` | `FromHttp((sp,a)=>factory…)` | +| **tenant** | `FromFile(a=>$"t/{a.Tenant}/db.json").TenantScoped()` *(works today)* | `FromStorage((sp,a)=>new Marten(store,a.Tenant)).TenantScoped()` | + +`.TenantScoped()` is a layer-agnostic modifier, valid in **both** methods. The gates **compose**: `.TenantScoped()` adds a "tenant present" gate; Layer 2 adds an "`sp` present" gate. **Marten-per-tenant** = both gates → runs only in a tenant pipeline post-container. Do **not** restrict tenant rules to Layer 2 — that would couple tenancy to DI and kill no-DI multi-tenant scenarios (file-per-tenant in a CLI / embedded lib). + +### 6. Reactive contract (load-bearing) + +- **Layer-2 activation is a RECOMPUTE on the existing pipeline (same backplane), never a rebuild.** A rebuild would orphan every previously-obtained reactive view. +- Therefore **all live `IReactiveConfig` views receive the Layer-2 update, regardless of when they were obtained** — they are all views over the same `MasterBackplane.SnapshotStream`, and Layer-2 activation is just another committed snapshot. A view obtained *pre-container* (e.g. to drive a Serilog `LoggingLevelSwitch`) gets the Layer-2 value when it lands, then every subsequent poll change. + +```csharp +var levelSwitch = new LoggingLevelSwitch(); +var live = mgr.GetReactiveConfig(); // pre-container is fine +live.Subscribe(c => levelSwitch.MinimumLevel = Map(c.Level)); +// fires: now (Layer-1 file level) → on Layer-2 activation (remote level) → on every poll change after +``` + +Note: this requires a **subscription** (push), not a one-time `.CurrentValue` read. (Driving the actual MEL `ILogger` filters still needs an explicit bridge from the reactive value to `LoggerFilterOptions` — that is a logging-integration concern, not part of this ADR.) + +### 7. Readiness contract (= `IOptions` semantics) + +- Layer-2 values are **guaranteed after host start**. +- A snapshot read (`GetConfig()`) **before** host start returns the **Layer-1 base** value; a reactive subscriber gets the upgrade when Layer 2 activates. +- A type that exists **only** in Layer 2 is unresolved (null) before host start. + +### 8. Failure semantics + +Layer-2 rules should typically be **optional**: if a Layer-2 source fails (DB/HTTP down), the recompute rolls back to the last good state → **Layer-1 values persist**, health is degraded. A remote outage must not nuke the whole config. + +### 9. Lifetime discipline (the holder is the ROOT provider) + +The holder's `sp` is the **root** `IServiceProvider` (the activation hosted service is root-constructed; we are **not** in a request scope). Therefore: + +- Resolve **singletons / factories only** — `IDocumentStore` (Marten), `IDbContextFactory` (EF), `IHttpClientFactory`. Open **short-lived units per read** on the recompute thread (`store.QuerySession(a.Tenant)`, `factory.CreateDbContext()`, `factory.CreateClient(...)`). **Never** resolve a scoped service from root (captive-dependency bug). +- This is correct, not a limitation: config is computed **once per tenant/global, cached, reactive — not per request.** The request scope is irrelevant to a config recompute. (If a source ever genuinely needs a scoped service, create a scope per recompute — rarely needed.) + +### 10. HTTP provider gains an `IHttpClientFactory`-backed path + +Today `HttpProvider` does `new HttpClient()`. Add a Layer-2 overload that resolves `IHttpClientFactory` from the holder and uses a named client — gaining handler pooling/rotation, Polly via `AddHttpClient`, etc. The current `new HttpClient()` / `HttpMessageHandler?` path stays for Layer 1 / no-DI. + +### 11. Consumption-tenant adapter (separate, additive, future) + +Distinct from the **source-tenant** flow above (`a.Tenant`, build side) is the **consumption-tenant** flow: "this request's tenant's config via injection." That is a *separate* concern and is **not part of this ADR's core**, but is the natural follow-on: + +- The app supplies a **scoped `ITenantContext`** (reads the current tenant from `HttpContext`: claim/header/route — only the app knows this). +- A **scoped `ITenantReactiveConfig`** adapter (in `Cocoar.Configuration.AspNetCore`) reads `ITenantContext.Current` and delegates to `mgr.GetReactiveConfigForTenant(tenant)`. +- Scoped/transient consumers only; a singleton can never have an ambient tenant → it uses explicit `GetReactiveConfigForTenant(id)`. +- **Trap:** do **not** re-register `IReactiveConfig` itself as scoped (it is a singleton today; that would break singletons injecting it). Use a **distinct** `ITenantReactiveConfig`. + +It needs nothing from this ADR (only the existing `GetReactiveConfigForTenant`), so it can ship independently, any time. + +--- + +## Non-breaking guarantees + +Existing consumers (Layer-1-only) are untouched, **if** we hold three rules: + +1. **Layer 1 (`UseConfiguration`) stays eager and identical** — readiness, timing, even the "I/O at registration" behavior. Only the new opt-in Layer 2 is lazy. +2. **Everything new is additive** — new builder extension, new `(sp,a)=>` factory overloads, new types. **No existing signature changes** (do not touch `IConfigurationAccessor` / `ConfigurationProvider` in a breaking way). +3. **The activation hosted service is registered only when Layer-2 rules exist** → zero impact for apps that do not opt in. + +Plus the §11 trap: never re-register `IReactiveConfig` as scoped. + +--- + +## Engine / package impact + +| Area | Change | Kind | +|---|---|---| +| Core `RuleManager.ShouldSkip` | reused for the `sp`-gate (via the existing `When` predicate) — no change needed | Reuse | +| Core `ConfigurationEngine.ScheduleRecompute(startIndex)` + `RestorePrefixContributions` | reused to run the Layer-2 activation recompute | Reuse | +| Core `ConfigManagerBuilder` | likely **one small internal hook** to append satellite-supplied rules | Additive (internal) | +| **NEW** `Cocoar.Configuration.DI`: `ServiceProviderHolder` + `UseServiceBackedConfiguration` extension + `sp`-aware factory overloads (`FromStorage`, …) + activation `IHostedService` | the whole Layer-2 mechanism | **New (satellite)** | +| `Cocoar.Configuration.Http` | `FromHttp((sp,a)=>…)` overload resolving `IHttpClientFactory` | Additive | +| (Future) `Cocoar.Configuration.AspNetCore` | scoped `ITenantReactiveConfig` (§11) | Additive | + +**Net:** the core gains essentially nothing DI-specific (a small internal append hook at most); the entire DI integration lives in the satellite packages. The No-DI core is preserved. + +--- + +## Consequences + +✅ DB-backed config (Marten/EF) and `IHttpClientFactory`-backed HTTP become possible — the headline enterprise scenarios +✅ Marten-per-tenant config falls out of composing the tenant gate + the `sp` gate +✅ Bootstrap config (eager, Layer 1) + remote/DI-backed override (lazy, Layer 2) in **one** reactive value — nicer than juggling `IConfiguration`/`IOptions`/`IOptionsMonitor` +✅ No-DI core preserved; fully additive/opt-in; non-breaking for existing consumers +✅ Removes a latent smell: today file/HTTP I/O runs at *service registration* time; Layer-2 work moves to container-owned time + +⚠️ A readiness contract exists (Layer-2 values after host start) — must be documented; consumers needing the upgrade must subscribe, not snapshot +⚠️ The activation timing vs. consumers resolved *during* `BuildServiceProvider` needs care (hosted service runs after build, before serving) +⚠️ DB sources have no push change-detection by default → poll or Postgres `LISTEN/NOTIFY` (separate work) +⚠️ Precedence is bucketed (all Layer 1 before all Layer 2); the rare "non-DI must beat DI-backed" is handled by placing the non-DI rule in Layer 2 (§4), not across the bucket boundary + +--- + +## Open questions — resolved in the implementation + +- **Gating granularity:** ✅ per-`sp`-usage. Each `sp`-using overload attaches a dedicated `ActivationGate`; a non-`sp` rule placed in Layer 2 runs eagerly and still wins by position. +- **Naming:** ✅ `UseServiceBackedConfiguration`; factory overloads `FromStorage((sp,a)=>IStorageBackend)` and `FromHttp((sp,a)=>HttpClient)`. +- **Activation hook:** ✅ `IHostedLifecycleService`, acting in `StartingAsync` (before any regular `IHostedService.StartAsync`), so Layer 2 is live before app/hosted-service code reads config. A manual `IServiceProvider.ActivateServiceBackedConfigurationAsync()` covers non-host scenarios; both are idempotent (the holder publishes the provider exactly once). Consumers that read a snapshot *during* container build see the Layer-1 base; the readiness contract (§7) requires a **subscription** to receive the upgrade. +- **Append-rules core seam:** ✅ `ConfigManagerBuilder.AddServiceBackedRules(IEnumerable)` appends after Layer 1 and records `ConfigManager.ServiceBackedLayerStartIndex`. The sp-gate ambient is the internal core `ServiceBackedRuleContext` (BCL `IServiceProvider` only; never on the public surface), read by both the DI and Http overloads. +- **DB change-detection:** still out of scope here — poll (via `FromStorage` on a polling backend) or app-driven `ReloadTenantAsync`/`RemoveTenantAsync`. Push (`LISTEN/NOTIFY`) remains separate, future work. + +--- + +## Alternatives considered + +- **Make DI mandatory / delete the No-DI core** — rejected. The No-DI core is the test/CLI/embedding/alt-container moat; the DI majority is served by making the DI path the blessed default, not by removing No-DI. +- **Make everything lazy (Layer 1 too)** — rejected. Breaks config-driven DI registration (you must read config *while* building the `ServiceCollection`), breaks "config ready when `AddCocoarConfiguration` returns", and would be a breaking change. +- **`sp` on the outer rule-list lambda (`(rule, sp) => […]`)** — rejected. The DI plan needs the Layer-2 *type list* at registration (no `sp`); `sp` must flow into the provider *factories*, not the enumerable lambda. +- **Build an intermediate `ServiceProvider` at registration to feed providers** — rejected (well-known anti-pattern: a second container, duplicate singletons, disposal chaos). +- **Restrict tenant rules to Layer 2** — rejected (couples tenancy to DI; kills no-DI multi-tenant; §5). + +--- + +## References + +- ADR-005 — multi-tenancy (the `.TenantScoped()` gate + `GetReactiveConfigForTenant` this builds on) +- `src/Cocoar.Configuration.DI/CocoarConfigurationExtensions.cs` — the eager `ConfigManager.Create` + `AddSingleton(instance)` to be made container-owned for Layer 2 +- `src/Cocoar.Configuration.Http/HttpProvider.cs` — the `new HttpClient()` to get an `IHttpClientFactory` overload +- `src/Cocoar.Configuration/Rules/RuleManager.cs` — `ShouldSkip` (the gate machinery) +- `src/Cocoar.Configuration/Core/ConfigurationEngine.cs` — `ScheduleRecompute(startIndex)` + `RestorePrefixContributions` (the activation recompute) +- Microsoft `IConfiguration` / `IOptions` — the proven two-layer precedent diff --git a/src/Cocoar.Configuration.AspNetCore/Tenant/ITenantContext.cs b/src/Cocoar.Configuration.AspNetCore/Tenant/ITenantContext.cs new file mode 100644 index 0000000..be76f45 --- /dev/null +++ b/src/Cocoar.Configuration.AspNetCore/Tenant/ITenantContext.cs @@ -0,0 +1,13 @@ +namespace Cocoar.Configuration.AspNetCore; + +/// +/// Supplies the current request's tenant id for scoped, per-request configuration consumption (ADR-006 §11). +/// The application implements this — reading a claim, header, or route value; only the app knows where the +/// tenant lives — and registers it as scoped, or relies on the default registered by +/// AddCocoarTenantReactiveConfig(resolver). +/// +public interface ITenantContext +{ + /// The current tenant id, or null when none is resolved for this request. + string? Current { get; } +} diff --git a/src/Cocoar.Configuration.AspNetCore/Tenant/ITenantReactiveConfig.cs b/src/Cocoar.Configuration.AspNetCore/Tenant/ITenantReactiveConfig.cs new file mode 100644 index 0000000..5257bd6 --- /dev/null +++ b/src/Cocoar.Configuration.AspNetCore/Tenant/ITenantReactiveConfig.cs @@ -0,0 +1,17 @@ +using Cocoar.Configuration.Reactive; + +namespace Cocoar.Configuration.AspNetCore; + +/// +/// A scoped reactive configuration view bound to the current request's tenant (from +/// ) — ADR-006 §11. Inject this into scoped/transient consumers to get THIS +/// tenant's effective configuration; it delegates to ConfigManager.GetReactiveConfigForTenant<T>(tenant). +/// +/// A singleton can never have an ambient tenant — it must call GetReactiveConfigForTenant<T>(id) +/// explicitly. This is a distinct interface from (which stays the global, +/// singleton view), so injecting one never breaks the other (the ADR-006 §11 trap). +/// +/// +public interface ITenantReactiveConfig : IReactiveConfig +{ +} diff --git a/src/Cocoar.Configuration.AspNetCore/Tenant/TenantReactiveConfig.cs b/src/Cocoar.Configuration.AspNetCore/Tenant/TenantReactiveConfig.cs new file mode 100644 index 0000000..74be5c0 --- /dev/null +++ b/src/Cocoar.Configuration.AspNetCore/Tenant/TenantReactiveConfig.cs @@ -0,0 +1,39 @@ +using Cocoar.Configuration.Core; +using Cocoar.Configuration.Reactive; + +namespace Cocoar.Configuration.AspNetCore; + +/// +/// Scoped adapter that binds to the current request's tenant +/// (from ) and delegates to ConfigManager.GetReactiveConfigForTenant<T>. +/// +internal sealed class TenantReactiveConfig : ITenantReactiveConfig +{ + private readonly Lazy> _inner; + + public TenantReactiveConfig(ConfigManager configManager, ITenantContext tenantContext) + { + ArgumentNullException.ThrowIfNull(configManager); + ArgumentNullException.ThrowIfNull(tenantContext); + + // Resolved lazily on first use: the tenant pipeline must already be initialized (e.g. via + // EnsureTenantInitializedAsync in middleware). Binding to ITenantContext.Current here ties this scoped + // view to THIS request's tenant. + _inner = new Lazy>(() => + { + var tenant = tenantContext.Current; + if (string.IsNullOrWhiteSpace(tenant)) + { + throw new InvalidOperationException( + "No tenant resolved in ITenantContext for the current request. ITenantReactiveConfig " + + "requires a tenant; a singleton must use ConfigManager.GetReactiveConfigForTenant(id) explicitly."); + } + + return configManager.GetReactiveConfigForTenant(tenant); + }); + } + + public T CurrentValue => _inner.Value.CurrentValue; + + public IDisposable Subscribe(IObserver observer) => _inner.Value.Subscribe(observer); +} diff --git a/src/Cocoar.Configuration.AspNetCore/Tenant/TenantReactiveConfigExtensions.cs b/src/Cocoar.Configuration.AspNetCore/Tenant/TenantReactiveConfigExtensions.cs new file mode 100644 index 0000000..7febb83 --- /dev/null +++ b/src/Cocoar.Configuration.AspNetCore/Tenant/TenantReactiveConfigExtensions.cs @@ -0,0 +1,54 @@ +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; + +namespace Cocoar.Configuration.AspNetCore; + +/// +/// Registration for the scoped per-request tenant configuration adapter (ADR-006 §11). +/// +public static class TenantReactiveConfigExtensions +{ + /// + /// Registers the scoped adapter so scoped/transient consumers can + /// inject the current request's tenant configuration. Leaves the singleton IReactiveConfig<T> + /// registration untouched (the §11 trap). + /// + /// The application must also register a scoped — either its own, or the default + /// registered here when is supplied. Tenants must be initialized + /// (e.g. EnsureTenantInitializedAsync in middleware) before the adapter is used in a request. + /// + /// + /// The service collection. + /// Optional resolver of the tenant id from the current + /// (a claim, header, or route value). When provided, a default scoped and + /// are registered. + public static IServiceCollection AddCocoarTenantReactiveConfig( + this IServiceCollection services, + Func? tenantResolver = null) + { + ArgumentNullException.ThrowIfNull(services); + + services.TryAddScoped(typeof(ITenantReactiveConfig<>), typeof(TenantReactiveConfig<>)); + + if (tenantResolver is not null) + { + services.AddHttpContextAccessor(); + services.TryAddScoped(sp => + { + var httpContext = sp.GetRequiredService().HttpContext; + return new DelegateTenantContext(httpContext is null ? null : tenantResolver(httpContext)); + }); + } + + return services; + } +} + +/// An carrying a pre-resolved tenant id (used by the default resolver path). +internal sealed class DelegateTenantContext : ITenantContext +{ + public DelegateTenantContext(string? current) => Current = current; + + public string? Current { get; } +} diff --git a/src/Cocoar.Configuration.DI/Cocoar.Configuration.DI.csproj b/src/Cocoar.Configuration.DI/Cocoar.Configuration.DI.csproj index c0ead92..a28a818 100644 --- a/src/Cocoar.Configuration.DI/Cocoar.Configuration.DI.csproj +++ b/src/Cocoar.Configuration.DI/Cocoar.Configuration.DI.csproj @@ -12,6 +12,8 @@ + + diff --git a/src/Cocoar.Configuration.DI/CocoarConfigurationExtensions.cs b/src/Cocoar.Configuration.DI/CocoarConfigurationExtensions.cs index 4bc8d1c..9db406a 100644 --- a/src/Cocoar.Configuration.DI/CocoarConfigurationExtensions.cs +++ b/src/Cocoar.Configuration.DI/CocoarConfigurationExtensions.cs @@ -19,6 +19,11 @@ public static IServiceCollection AddCocoarConfiguration( var plan = ServiceRegistrationPlanner.CreatePlan(configManager); ServiceDescriptorEmitter.Emit(services, plan, configManager); + // ADR-006: if UseServiceBackedConfiguration added Layer-2 rules, register the holder + activation hosted + // service so they come alive on host start. No-op otherwise (zero impact for Layer-1-only apps). Wired + // HERE — the single point every entry path funnels through (DI Action overload, AspNetCore, manual). + ServiceBackedConfigurationCoordinator.WireActivation(services, configManager); + return services; } @@ -34,7 +39,7 @@ public static IServiceCollection AddCocoarConfiguration( services.ThrowIfAlreadyRegistered(); var configManager = ConfigManager.Create(configure); - services.AddCocoarConfiguration(configManager); + services.AddCocoarConfiguration(configManager); // WireActivation runs inside the instance overload return services; } diff --git a/src/Cocoar.Configuration.DI/ServiceBacked/ServiceBackedConfigurationActivator.cs b/src/Cocoar.Configuration.DI/ServiceBacked/ServiceBackedConfigurationActivator.cs new file mode 100644 index 0000000..202efc2 --- /dev/null +++ b/src/Cocoar.Configuration.DI/ServiceBacked/ServiceBackedConfigurationActivator.cs @@ -0,0 +1,40 @@ +using Cocoar.Configuration.Core; +using Microsoft.Extensions.Hosting; + +namespace Cocoar.Configuration.DI; + +/// +/// Activates service-backed (Layer-2, ADR-006) configuration on host start. Implemented as +/// and acting in before any +/// regular IHostedService.StartAsync — so Layer-2 values are live before application code and other +/// hosted services read configuration. It publishes the root provider to the holder and awaits the activation +/// recompute, satisfying the readiness contract. +/// +internal sealed class ServiceBackedConfigurationActivator : IHostedLifecycleService +{ + private readonly ConfigManager _manager; + private readonly ServiceProviderHolder _holder; + private readonly int _startIndex; + private readonly IServiceProvider _rootServiceProvider; + + public ServiceBackedConfigurationActivator( + ConfigManager manager, + ServiceProviderHolder holder, + int startIndex, + IServiceProvider rootServiceProvider) + { + _manager = manager; + _holder = holder; + _startIndex = startIndex; + _rootServiceProvider = rootServiceProvider; + } + + public Task StartingAsync(CancellationToken cancellationToken) + => ServiceBackedConfigurationCoordinator.ActivateAsync(_manager, _holder, _startIndex, _rootServiceProvider); + + public Task StartAsync(CancellationToken cancellationToken) => Task.CompletedTask; + public Task StartedAsync(CancellationToken cancellationToken) => Task.CompletedTask; + public Task StoppingAsync(CancellationToken cancellationToken) => Task.CompletedTask; + public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask; + public Task StoppedAsync(CancellationToken cancellationToken) => Task.CompletedTask; +} diff --git a/src/Cocoar.Configuration.DI/ServiceBacked/ServiceBackedConfigurationCoordinator.cs b/src/Cocoar.Configuration.DI/ServiceBacked/ServiceBackedConfigurationCoordinator.cs new file mode 100644 index 0000000..bae0737 --- /dev/null +++ b/src/Cocoar.Configuration.DI/ServiceBacked/ServiceBackedConfigurationCoordinator.cs @@ -0,0 +1,107 @@ +using Cocoar.Configuration.Core; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; + +namespace Cocoar.Configuration.DI; + +/// +/// Internal orchestration for service-backed (Layer-2, ADR-006) configuration: owns the holder lifecycle, +/// the DI wiring, and the two-phase activation recompute. Everything DI-specific lives here so the No-DI core +/// stays untouched. +/// +internal static class ServiceBackedConfigurationCoordinator +{ + /// + /// Returns the holder attached to , creating and attaching it on first use. + /// Stored on the manager as so the core never names a DI type; multiple + /// UseServiceBackedConfiguration calls share one holder. + /// + internal static ServiceProviderHolder GetOrCreateHolder(ConfigManager manager) + { + if (manager.ServiceBackedHolder is ServiceProviderHolder existing) + { + return existing; + } + + var holder = new ServiceProviderHolder(); + manager.ServiceBackedHolder = holder; + return holder; + } + + /// + /// Registers the holder singleton and the activation hosted service — but ONLY when service-backed + /// (Layer-2) rules were actually configured. Apps that do not opt in get zero impact (non-breaking rule 3). + /// + internal static void WireActivation(IServiceCollection services, ConfigManager manager) + { + if (manager.ServiceBackedHolder is not ServiceProviderHolder holder) + { + return; + } + + var startIndex = manager.ServiceBackedLayerStartIndex; + + services.AddSingleton(holder); + + // Captures the ROOT provider (a singleton's factory sp is always root) so the MANUAL activation overload + // can recover root even when handed a scoped provider — never capturing a scope that later disposes + // (ADR-006 §9 lifetime discipline). + services.AddSingleton(sp => new RootServiceProviderAccessor(sp)); + + // AddSingleton (not AddHostedService) so we can capture holder/manager/index. The + // factory's sp is the ROOT provider (singleton resolution). + services.AddSingleton(sp => + new ServiceBackedConfigurationActivator(manager, holder, startIndex, sp)); + } + + /// + /// Activates Layer 2: publishes the root provider, then triggers a RECOMPUTE on the existing pipeline + /// (never a rebuild) from the Layer-2 boundary. The prefix (Layer 1) is restored unchanged and the now + /// un-gated Layer-2 suffix runs, so every live IReactiveConfig<T> view — whenever obtained — + /// receives the update over the same backplane snapshot stream. Runs once; concurrent activators await the + /// same activation (so all observe the readiness guarantee, not just the first). + /// + internal static Task ActivateAsync( + ConfigManager manager, + ServiceProviderHolder holder, + int startIndex, + IServiceProvider rootServiceProvider) + => holder.Activate(rootServiceProvider, () => RunActivationAsync(manager, startIndex)); + + private static async Task RunActivationAsync(ConfigManager manager, int startIndex) + { + var recomputeIndex = startIndex < 0 ? 0 : startIndex; + + // DIRECT, semaphore-guarded recompute awaited to completion — NOT the cancel-on-reschedule scheduler — so + // a concurrent Layer-1 change cannot cancel activation before Layer 2 has committed (readiness, ADR-006 §7). + try + { + await manager.RecomputeNowAsync(recomputeIndex).ConfigureAwait(false); + } + catch + { + // Optional Layer-2 sources degrade to the Layer-1 snapshot (engine logs + health). Never fault startup. + } + + // Fan out to tenant pipelines built BEFORE activation — their sp-gated rules were skipped at init and + // would otherwise never re-run. (Tenants built after activation already saw the active gate at init.) + try + { + await manager.RecomputeInitializedTenantsNowAsync(recomputeIndex).ConfigureAwait(false); + } + catch + { + // Per-tenant failures are already isolated inside the fan-out; this is a final safety net. + } + } +} + +/// +/// Captures the application root . Registered as a singleton, so its factory +/// always receives the root provider — lets the manual activation overload recover root from any (even scoped) +/// provider it is handed. +/// +internal sealed class RootServiceProviderAccessor(IServiceProvider root) +{ + public IServiceProvider Root { get; } = root; +} diff --git a/src/Cocoar.Configuration.DI/ServiceBacked/ServiceBackedConfigurationExtensions.cs b/src/Cocoar.Configuration.DI/ServiceBacked/ServiceBackedConfigurationExtensions.cs new file mode 100644 index 0000000..4265c7f --- /dev/null +++ b/src/Cocoar.Configuration.DI/ServiceBacked/ServiceBackedConfigurationExtensions.cs @@ -0,0 +1,88 @@ +using Cocoar.Configuration.Core; +using Cocoar.Configuration.Fluent; +using Cocoar.Configuration.Rules; +using Microsoft.Extensions.DependencyInjection; + +namespace Cocoar.Configuration.DI; + +/// +/// The DI-package authoring surface for service-backed (Layer-2, ADR-006) configuration: rules whose provider +/// factories receive the application (e.g. FromStorage, +/// FromHttp((sp,a)=>…)). Layer 1 (UseConfiguration) stays eager and DI-free; Layer 2 is lazy +/// and container-owned, activated on host start. +/// +public static class ServiceBackedConfigurationExtensions +{ + /// + /// Adds a service-backed (Layer-2) rule list whose factories may resolve services from the + /// application container. These rules merge after the Layer-1 (UseConfiguration) rules + /// (later precedence) and stay dormant until the host starts, then activate via a recompute that updates + /// every live reactive view. .TenantScoped() composes here too (Marten-per-tenant = tenant gate + + /// sp gate). + /// + /// + /// + /// services.AddCocoarConfiguration(c => c + /// .UseConfiguration(rules => [ + /// rules.For<LogConfig>().FromFile("appsettings.json") + /// ]) + /// .UseServiceBackedConfiguration(rules => [ + /// rules.For<LogConfig>().FromHttp( + /// (sp, a) => sp.GetRequiredService<IHttpClientFactory>().CreateClient("cocoar-config"), + /// "logging.json", pollInterval: TimeSpan.FromSeconds(30)), + /// rules.For<TenantSettings>().FromStorage( + /// (sp, a) => new MartenConfigBackend(sp.GetRequiredService<IDocumentStore>(), a.Tenant)) + /// .TenantScoped() + /// ])); + /// + /// + /// The configuration builder. + /// A function that builds the service-backed rules using the fluent API; its sp-using + /// factories (FromStorage, FromHttp((sp,a)=>…)) read the container at recompute time. + public static ConfigManagerBuilder UseServiceBackedConfiguration( + this ConfigManagerBuilder builder, + Func rules) + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentNullException.ThrowIfNull(rules); + + var manager = ConfigManagerBuilder.GetManager(builder); + var holder = ServiceBackedConfigurationCoordinator.GetOrCreateHolder(manager); + + // The context exposes the activation signal + provider as late-bound values over the shared holder. It is + // carried by the ServiceBackedProviderBuilder handed to each For() and read by the sp-using overloads + // (FromStorage / FromHttp((sp,a)=>…) / third-party ones) — no ambient state. + var context = new ServiceBackedRuleContext( + isActive: () => holder.HasServiceProvider, + serviceProvider: () => holder.ServiceProvider!); + + var layer2Rules = rules(new ServiceBackedRulesBuilder(context)) ?? []; + + builder.AddServiceBackedRules(layer2Rules); + return builder; + } + + /// + /// Manually activates service-backed configuration for hosts that do not run an IHost (e.g. a console + /// app that builds its own ). Pass the root provider. No-op if no + /// service-backed rules were registered, and idempotent with the automatic hosted-service activation. + /// + /// The application root service provider. + public static Task ActivateServiceBackedConfigurationAsync(this IServiceProvider serviceProvider) + { + ArgumentNullException.ThrowIfNull(serviceProvider); + + var holder = serviceProvider.GetService(); + if (holder is null) + { + return Task.CompletedTask; // no service-backed configuration was registered — nothing to activate + } + + // Always use the ROOT provider for the holder, even if a scoped provider was passed: the holder's sp is + // read on every later recompute/poll, so capturing a scope that disposes would fault Layer-2 reads (§9). + var root = serviceProvider.GetService()?.Root ?? serviceProvider; + var manager = serviceProvider.GetRequiredService(); + return ServiceBackedConfigurationCoordinator.ActivateAsync( + manager, holder, manager.ServiceBackedLayerStartIndex, root); + } +} diff --git a/src/Cocoar.Configuration.DI/ServiceBacked/ServiceBackedRulesExtensions.cs b/src/Cocoar.Configuration.DI/ServiceBacked/ServiceBackedRulesExtensions.cs new file mode 100644 index 0000000..d1f1253 --- /dev/null +++ b/src/Cocoar.Configuration.DI/ServiceBacked/ServiceBackedRulesExtensions.cs @@ -0,0 +1,51 @@ +using Cocoar.Configuration.Core; +using Cocoar.Configuration.Fluent; +using Cocoar.Configuration.Providers; + +namespace Cocoar.Configuration.DI; + +/// +/// Service-backed (Layer-2, ADR-006) rule factories that need the application . +/// Valid only inside UseServiceBackedConfiguration(...). +/// +public static class ServiceBackedRulesExtensions +{ + /// + /// Creates a service-backed storage rule whose is built from the + /// application container — e.g. a Marten/EF backend over a DI-managed IDocumentStore / + /// IDbContextFactory<T>. Reuses the (tenant-keyed) LocalStorage backend pipeline, so it also + /// exposes the ILocalStorage<T> write facade and composes with .TenantScoped() for + /// per-tenant, DB-backed configuration. + /// + /// + /// Only valid inside UseServiceBackedConfiguration(...). The rule stays dormant until the host + /// starts; the factory is invoked at recompute time, never before the container exists. The + /// is the ROOT provider: resolve singletons/factories and open short-lived + /// units per read (store.QuerySession(a.Tenant)), never a scoped service (ADR-006 §9). + /// + /// The typed provider builder. + /// Factory receiving the root and the current + /// (its Tenant is set in a tenant pipeline) and returning the + /// to read configuration from. + public static ProviderRuleBuilder + FromStorage( + this ServiceBackedProviderBuilder builder, + Func backendFactory) + where T : class + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentNullException.ThrowIfNull(backendFactory); + + var context = builder.Context; + + // Reuse the core LocalStorage backend-factory rule (already tenant-keyed: one store per tenant, the + // backend swapped per recompute). currentBackend is ignored — a backend wrapping a DI singleton is cheap + // to re-create and opens its short-lived units per read. + var rule = builder.FromLocalStorage( + (accessor, _) => backendFactory(context.ServiceProvider, accessor)); + + // sp-gate: dormant until the container is built; composes (AND) with any .TenantScoped() the caller adds, + // so Marten-per-tenant runs only inside a tenant pipeline post-container. + return rule.WithActivationGate(_ => context.IsActive); + } +} diff --git a/src/Cocoar.Configuration.DI/ServiceBacked/ServiceProviderHolder.cs b/src/Cocoar.Configuration.DI/ServiceBacked/ServiceProviderHolder.cs new file mode 100644 index 0000000..60580f8 --- /dev/null +++ b/src/Cocoar.Configuration.DI/ServiceBacked/ServiceProviderHolder.cs @@ -0,0 +1,45 @@ +namespace Cocoar.Configuration.DI; + +/// +/// Holds the application root for service-backed (Layer-2, ADR-006) +/// configuration. Null until the container is built; set once — on host start — by the activation hosted +/// service (or a manual ActivateServiceBackedConfiguration call). The instance is captured in the +/// closures of the sp-gated Layer-2 rules so their factories can resolve services lazily at recompute time, +/// and is registered as a DI singleton so the activator receives the very same instance. +/// +internal sealed class ServiceProviderHolder +{ + private readonly object _gate = new(); + private volatile IServiceProvider? _serviceProvider; + private Task? _activationTask; + + /// The application root provider once published; otherwise null. + internal IServiceProvider? ServiceProvider => _serviceProvider; + + /// True once the container has been published (the Layer-2 activation recompute may run). + internal bool HasServiceProvider => _serviceProvider is not null; + + /// + /// Publishes the root provider and starts the activation recompute exactly once. Every caller — the CAS winner + /// and any concurrent loser — receives the SAME activation , so all of them observe the + /// readiness guarantee (not just the first), and the recompute runs once. The provider is published BEFORE the + /// activation runs, so the sp-gated rules see it. + /// + internal Task Activate(IServiceProvider serviceProvider, Func activation) + { + ArgumentNullException.ThrowIfNull(serviceProvider); + ArgumentNullException.ThrowIfNull(activation); + + lock (_gate) + { + if (_activationTask is not null) + { + return _activationTask; + } + + _serviceProvider = serviceProvider; // publish before the activation recompute reads it + _activationTask = activation(); + return _activationTask; + } + } +} diff --git a/src/Cocoar.Configuration.Http/HttpProvider.cs b/src/Cocoar.Configuration.Http/HttpProvider.cs index fa7fce7..299d5c8 100644 --- a/src/Cocoar.Configuration.Http/HttpProvider.cs +++ b/src/Cocoar.Configuration.Http/HttpProvider.cs @@ -8,23 +8,44 @@ namespace Cocoar.Configuration.Http; /// /// HTTP configuration provider supporting one-time fetch, polling, and Server-Sent Events (SSE). /// -public sealed class HttpProvider(HttpProviderOptions options) - : ConfigurationProvider(options), IDisposable +public sealed class HttpProvider + : ConfigurationProvider, IDisposable { - private readonly HttpClient _client = CreateClient(options); + // Owned client (handler / default path) — created once and disposed by us. Null for the service-backed path. + private readonly HttpClient? _ownedClient; + + // Service-backed (Layer-2) path: a factory over IHttpClientFactory. Re-invoked PER fetch / SSE connection so + // the factory can hand out a client with a rotated, pooled handler (HandlerLifetime). Clients it returns are + // factory-owned and never disposed here. + private readonly Func? _clientFactory; + private bool _disposed; private int _consecutiveFailures; - private static HttpClient CreateClient(HttpProviderOptions opts) + public HttpProvider(HttpProviderOptions options) + : base(options) { - if (opts.Handler is not null) + if (options.ClientFactory is { } factory) { - return new(opts.Handler, disposeHandler: false); + _clientFactory = factory; + } + else if (options.Handler is not null) + { + _ownedClient = new(options.Handler, disposeHandler: false); + } + else + { + _ownedClient = new(); } - - return new(); } + /// + /// The for a single fetch or SSE connection. Service-backed: a fresh, pooled-handler + /// client from the factory each call (the canonical IHttpClientFactory usage — cheap, not disposed). + /// Owned: the single long-lived client we created. + /// + internal HttpClient AcquireClient() => _clientFactory is not null ? _clientFactory() : _ownedClient!; + public override async Task FetchConfigurationBytesAsync(HttpProviderQueryOptions query, CancellationToken ct = default) { return await FetchAsync(query, ct).ConfigureAwait(false); @@ -63,7 +84,13 @@ public void Dispose() } _disposed = true; - Safety.DisposeQuietly(_client); + + // Only dispose a client we created. A client sourced from IHttpClientFactory is owned by the factory's + // handler pool — disposing it here would be wrong (and there is none to dispose in that path). + if (_ownedClient is not null) + { + Safety.DisposeQuietly(_ownedClient); + } } /// @@ -71,12 +98,13 @@ public void Dispose() /// internal async Task FetchAsync(HttpProviderQueryOptions query, CancellationToken ct) { - var url = BuildUrl(_client, query.Url); + var client = AcquireClient(); + var url = BuildUrl(client, query.Url); using var req = new HttpRequestMessage(HttpMethod.Get, url); ApplyHeaders(req, query.Headers); - var resp = await _client.SendAsync(req, ct).ConfigureAwait(false); + var resp = await client.SendAsync(req, ct).ConfigureAwait(false); resp.EnsureSuccessStatusCode(); return await resp.Content.ReadAsByteArrayAsync(ct).ConfigureAwait(false); } @@ -116,8 +144,6 @@ internal static string BuildUrl(HttpClient client, string pathOrAbsolute) internal int FailureThreshold => ProviderOptions.ErrorConsecutiveFailureThreshold; - internal HttpClient Client => _client; - internal HttpProviderOptions Options => ProviderOptions; /// @@ -169,7 +195,7 @@ internal static void HandleFetchFailure(HttpProvider provider, HttpProviderQuery var failures = provider.IncrementFailureCount(); if (failures >= provider.FailureThreshold) { - var url = BuildUrl(provider._client, query.Url); + var url = query.Url; // the configured path/URL; avoid acquiring a client on the failure path Trace.TraceWarning( "HttpProvider: {0} consecutive failures for '{1}' " + "(threshold: {2}). Last error: {3}: {4}. " + diff --git a/src/Cocoar.Configuration.Http/HttpProviderOptions.cs b/src/Cocoar.Configuration.Http/HttpProviderOptions.cs index ceb8a9f..689ab84 100644 --- a/src/Cocoar.Configuration.Http/HttpProviderOptions.cs +++ b/src/Cocoar.Configuration.Http/HttpProviderOptions.cs @@ -1,3 +1,4 @@ +using System.Text.Json; using System.Text.Json.Serialization; using Cocoar.Configuration.Providers.Abstractions; @@ -35,17 +36,48 @@ public sealed class HttpProviderOptions : IProviderConfiguration [JsonIgnore] public HttpMessageHandler? Handler { get; } + /// + /// Optional factory for an externally-owned — used by the service-backed + /// (Layer-2, ADR-006) FromHttp((sp,a)=>…) overload to source a client from + /// IHttpClientFactory. Invoked lazily, only when the provider is (re)built, so the + /// is read at recompute time. When set, the provider does NOT dispose the + /// client (the factory owns the pooled handler). Takes precedence over . + /// Not serialized for provider key generation. + /// + [JsonIgnore] + public Func? ClientFactory { get; } + public HttpProviderOptions( TimeSpan? pollInterval = null, bool serverSentEvents = false, TimeSpan? fallbackPollInterval = null, int errorConsecutiveFailureThreshold = 3, - HttpMessageHandler? handler = null) + HttpMessageHandler? handler = null, + Func? clientFactory = null) { PollInterval = pollInterval; ServerSentEvents = serverSentEvents; FallbackPollInterval = fallbackPollInterval; ErrorConsecutiveFailureThreshold = errorConsecutiveFailureThreshold <= 0 ? 3 : errorConsecutiveFailureThreshold; Handler = handler; + ClientFactory = clientFactory; } + + private static readonly JsonSerializerOptions ProviderKeyOptions = new() + { + DefaultIgnoreCondition = JsonIgnoreCondition.Never, + PropertyNamingPolicy = null, + WriteIndented = false, + }; + + /// + /// A service-backed client (from IHttpClientFactory) is externally owned and per-rule — two rules with + /// distinct clients must never collapse onto one shared . Since + /// is [JsonIgnore]d and so invisible to the default serialized key, return null (never share) in that + /// case — mirroring LocalStorageProviderOptions. The Layer-1 (no-factory) key is unchanged. + /// + public string? GenerateProviderKey() + => ClientFactory is not null + ? null + : JsonSerializer.Serialize(this, typeof(HttpProviderOptions), ProviderKeyOptions); } diff --git a/src/Cocoar.Configuration.Http/RulesExtensions.cs b/src/Cocoar.Configuration.Http/RulesExtensions.cs index f8efdc9..78c176a 100644 --- a/src/Cocoar.Configuration.Http/RulesExtensions.cs +++ b/src/Cocoar.Configuration.Http/RulesExtensions.cs @@ -48,4 +48,52 @@ public static ProviderRuleBuilder optionsFactory(cm).ToQueryOptions(), typeof(T) ); + + /// + /// Creates a service-backed (Layer-2, ADR-006) HTTP configuration rule whose underlying + /// is sourced from the application container — typically via + /// IHttpClientFactory, gaining handler pooling/rotation and AddHttpClient policies (Polly). + /// + /// + /// Only valid inside UseServiceBackedConfiguration(...) (it needs the container's + /// ). The rule stays dormant until the host starts; on activation it runs and + /// merges over the Layer-1 base, and every live IReactiveConfig<T> view receives the update. + /// The factory is invoked lazily, only when the provider is (re)built, never before the container exists. + /// + /// The typed provider builder. + /// Factory receiving the root and the current + /// (its Tenant is set in a tenant pipeline) and returning the + /// to use, e.g. (sp, a) => sp.GetRequiredService<IHttpClientFactory>().CreateClient("cocoar-config"). + /// The URL (absolute or relative to the client's base address) to fetch configuration from. + /// Optional polling interval. When null and SSE is disabled, configuration is fetched once. + /// When true, uses Server-Sent Events for live updates. + /// When SSE is enabled, falls back to polling at this interval on sustained SSE failure. + /// Optional HTTP headers sent with every request. + public static ProviderRuleBuilder + FromHttp(this ServiceBackedProviderBuilder builder, + Func clientFactory, + string url, + TimeSpan? pollInterval = null, + bool serverSentEvents = false, + TimeSpan? fallbackPollInterval = null, + IReadOnlyDictionary? headers = null) + where T : class + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentNullException.ThrowIfNull(clientFactory); + if (string.IsNullOrWhiteSpace(url)) + { + throw new ArgumentException("url is required", nameof(url)); + } + + // ServiceBacked hands us sp as a parameter invoked lazily at recompute time, and gates the rule + // automatically — no manual deferral or activation gate to get wrong. + return builder.ServiceBacked( + (sp, accessor) => new HttpProviderOptions( + pollInterval, + serverSentEvents, + fallbackPollInterval, + clientFactory: () => clientFactory(sp, accessor)), + _ => new HttpProviderQueryOptions(url, headers)); + } } diff --git a/src/Cocoar.Configuration.Http/SseObservable.cs b/src/Cocoar.Configuration.Http/SseObservable.cs index 2b6329d..bf00bf1 100644 --- a/src/Cocoar.Configuration.Http/SseObservable.cs +++ b/src/Cocoar.Configuration.Http/SseObservable.cs @@ -88,13 +88,15 @@ private async Task RunAsync(IObserver observer, CancellationToken ct) private async Task ConnectAndReadSseAsync(IObserver observer, CancellationToken ct) { - var url = HttpProvider.BuildUrl(provider.Client, query.Url); + // One client for this whole connection (service-backed: a fresh pooled-handler client per reconnect). + var client = provider.AcquireClient(); + var url = HttpProvider.BuildUrl(client, query.Url); using var req = new HttpRequestMessage(HttpMethod.Get, url); req.Headers.Accept.Add(new("text/event-stream")); HttpProvider.ApplyHeaders(req, query.Headers); - using var resp = await provider.Client.SendAsync(req, HttpCompletionOption.ResponseHeadersRead, ct) + using var resp = await client.SendAsync(req, HttpCompletionOption.ResponseHeadersRead, ct) .ConfigureAwait(false); resp.EnsureSuccessStatusCode(); diff --git a/src/Cocoar.Configuration.slnx b/src/Cocoar.Configuration.slnx index fc0bf56..dd4d09a 100644 --- a/src/Cocoar.Configuration.slnx +++ b/src/Cocoar.Configuration.slnx @@ -12,6 +12,7 @@ + @@ -26,6 +27,7 @@ + diff --git a/src/Cocoar.Configuration/Core/ConfigManager.cs b/src/Cocoar.Configuration/Core/ConfigManager.cs index 5dc0e58..4e62fcf 100644 --- a/src/Cocoar.Configuration/Core/ConfigManager.cs +++ b/src/Cocoar.Configuration/Core/ConfigManager.cs @@ -149,6 +149,21 @@ internal ConfigManager(IEnumerable rules, FuncUseEntitlements. Null when entitlements have not been configured. /// internal EntitlementsSetupData? EntitlementsSetup { get; set; } + + /// + /// The index of the first service-backed (Layer-2, ADR-006) rule in the global rule list, or -1 when + /// no service-backed rules are configured. The DI activation recompute restores the prefix below this index + /// (Layer 1 stays stable) and re-runs the suffix once the container's is set. + /// + internal int ServiceBackedLayerStartIndex { get; set; } = -1; + + /// + /// Opaque carrier for the DI package's ServiceProviderHolder (kept as so the + /// No-DI core never names a DI type). Set by UseServiceBackedConfiguration; read back by the DI + /// package after build to register the holder singleton and wire the activation hosted service. + /// + internal object? ServiceBackedHolder { get; set; } + internal MasterBackplane Backplane => _state.Backplane; internal ConfigManager Initialize() @@ -340,6 +355,55 @@ internal void ScheduleRecompute(int startIndex) => internal Task? CurrentRecomputeTask => _engine.CurrentRecomputeTask; + /// + /// Runs a recompute of the GLOBAL pipeline from directly to completion (under the + /// recompute semaphore), NOT via the cancel-on-reschedule scheduler. Used by the DI Layer-2 activation so a + /// concurrent provider-change signal cannot cancel activation before Layer 2 has committed (ADR-006 §7 readiness). + /// + internal Task RecomputeNowAsync(int startIndex, CancellationToken cancellationToken = default) => + _engine.RecomputeAndUpdateHealthAsync(_ruleManagers, this, startIndex, cancellationToken); + + /// + /// Runs the same direct recompute on every already-initialized tenant pipeline from . + /// Used on Layer-2 activation so tenants built before the container was published (their sp-gated rules were + /// skipped at init) pick up their service-backed values. Each tenant degrades independently. + /// + internal async Task RecomputeInitializedTenantsNowAsync(int startIndex, CancellationToken cancellationToken = default) + { + foreach (var lazy in _tenants.Values) + { + if (!lazy.IsValueCreated) + { + continue; + } + + var task = lazy.Value; + if (!task.IsCompletedSuccessfully) + { + continue; + } + + var pipeline = task.Result; + if (!pipeline.IsInitialized) + { + continue; + } + + try + { + // RecomputeAndUpdateHealthAsync swallows recompute failures; the per-tenant guard here additionally + // isolates the narrow dispose-race (a tenant removed mid-fan-out can surface ObjectDisposed / + // index races from its health update) so one removed/faulting tenant never blocks the others. + await pipeline.Engine.RecomputeAndUpdateHealthAsync( + pipeline.RuleManagers, pipeline.Accessor, startIndex, cancellationToken).ConfigureAwait(false); + } + catch + { + // Isolated: this tenant self-heals on its next provider change. + } + } + } + // ===== Tenant lifecycle (ADR-005 §4/§5, ITenantConfigurationAccessor) ===== /// diff --git a/src/Cocoar.Configuration/Core/ConfigManagerBuilder.cs b/src/Cocoar.Configuration/Core/ConfigManagerBuilder.cs index 87d7cb9..db30097 100644 --- a/src/Cocoar.Configuration/Core/ConfigManagerBuilder.cs +++ b/src/Cocoar.Configuration/Core/ConfigManagerBuilder.cs @@ -22,6 +22,11 @@ public sealed class ConfigManagerBuilder private readonly List> _afterBuildActions = new(); + // Service-backed (Layer-2, ADR-006) rules contributed by the DI package's UseServiceBackedConfiguration. + // They are appended AFTER the Layer-1 rules (later precedence) and, when sp-gated, stay dormant until the + // container is built. Kept separate from _rules so Layer 1 stays eager and byte-identical. + private readonly List _serviceBackedRules = new(); + internal ConfigManagerBuilder(ConfigManager manager) { _manager = manager; @@ -153,6 +158,33 @@ internal ConfigManagerBuilder AfterBuild(Action action) return this; } + /// + /// Internal seam used by the DI package's UseServiceBackedConfiguration to append satellite-supplied + /// service-backed (Layer-2, ADR-006) rules. They are placed AFTER the Layer-1 rules (later precedence). The + /// sp-gated ones are dormant during the eager build and activate on a post-container recompute. + /// + internal ConfigManagerBuilder AddServiceBackedRules(IEnumerable rules) + { + ArgumentNullException.ThrowIfNull(rules); + _serviceBackedRules.AddRange(rules); + return this; + } + + /// + /// Combines the Layer-1 rules with any appended service-backed (Layer-2) rules and records the Layer-2 + /// start index on the manager (the boundary the activation recompute restores the prefix below). + /// + private ConfigRule[] CombineWithServiceBackedRules(ConfigRule[] layer1Rules) + { + if (_serviceBackedRules.Count == 0) + { + return layer1Rules; + } + + _manager.ServiceBackedLayerStartIndex = layer1Rules.Length; + return layer1Rules.Concat(_serviceBackedRules).ToArray(); + } + internal ConfigManager Build() { ConfigRule[] configuredRules; @@ -167,6 +199,8 @@ internal ConfigManager Build() configuredRules = (_rules ?? (_ => []))(rulesBuilder); } + configuredRules = CombineWithServiceBackedRules(configuredRules); + _manager.Configure( configuredRules, _setup, @@ -197,6 +231,8 @@ internal async Task BuildAsync(CancellationToken cancellationToke configuredRules = (_rules ?? (_ => []))(rulesBuilder); } + configuredRules = CombineWithServiceBackedRules(configuredRules); + _manager.Configure( configuredRules, _setup, diff --git a/src/Cocoar.Configuration/Core/ConfigurationEngine.cs b/src/Cocoar.Configuration/Core/ConfigurationEngine.cs index 9a57391..7242d23 100644 --- a/src/Cocoar.Configuration/Core/ConfigurationEngine.cs +++ b/src/Cocoar.Configuration/Core/ConfigurationEngine.cs @@ -136,6 +136,32 @@ public void ScheduleRecompute( }); } + /// + /// Runs a recompute from directly to completion and updates health — the same + /// post-recompute work the lambda does, but awaitable and WITHOUT the + /// cancel-on-reschedule scheduler. Used by the DI Layer-2 activation so a concurrent change cannot cancel it + /// and so health reflects a degraded Layer-2 source. Never throws (failures are caught + health updated, + /// matching the scheduler path). + /// + public async Task RecomputeAndUpdateHealthAsync( + IReadOnlyList ruleManagers, + IConfigurationAccessor configAccessor, + int startIndex, + CancellationToken cancellationToken = default) + { + try + { + await RecomputeAllConfigurationsSafeAsync(ruleManagers, configAccessor, startIndex, cancellationToken).ConfigureAwait(false); + _state.UpdateHealth(); + } + catch (OperationCanceledException) { } + catch (Exception ex) + { + _logger.RuntimeRecomputeFailed(ex); + _state.UpdateHealth(); + } + } + /// /// Async variant of . Used by . /// diff --git a/src/Cocoar.Configuration/Core/ServiceBackedRuleContext.cs b/src/Cocoar.Configuration/Core/ServiceBackedRuleContext.cs new file mode 100644 index 0000000..3d3074b --- /dev/null +++ b/src/Cocoar.Configuration/Core/ServiceBackedRuleContext.cs @@ -0,0 +1,47 @@ +namespace Cocoar.Configuration.Core; + +/// +/// The container hook handed to service-backed (Layer-2, ADR-006) rule factories. Exposes the application +/// and the activation signal as late-bound values, so a rule's provider +/// options factory — built eagerly, before the container exists — can resolve services lazily at recompute time, +/// once the host has started. +/// +/// It is carried by (no ambient state) +/// and is the public seam a third-party provider package authors against: in a (sp, a) => … +/// fluent overload, read inside the options factory and gate the rule with +/// .WithActivationGate(_ => context.IsActive). Constructed by Cocoar.Configuration.DI; using only +/// the BCL keeps the No-DI core free of a DI-package dependency. +/// +/// +public sealed class ServiceBackedRuleContext +{ + private readonly Func _isActive; + private readonly Func _serviceProvider; + + internal ServiceBackedRuleContext(Func isActive, Func serviceProvider) + { + _isActive = isActive ?? throw new ArgumentNullException(nameof(isActive)); + _serviceProvider = serviceProvider ?? throw new ArgumentNullException(nameof(serviceProvider)); + } + + /// + /// True once the container is built and the Layer-2 activation recompute may run. Gate a service-backed rule + /// on it: .WithActivationGate(_ => context.IsActive) keeps the rule dormant until then. + /// + public bool IsActive => _isActive(); + + /// + /// The application root — valid only at recompute time (once + /// is true). Reading it earlier — e.g. in a fluent extension method body, which runs + /// eagerly before the container exists — throws with guidance, instead of silently yielding null. Prefer + /// ServiceBackedProviderBuilder<T>.ServiceBacked((sp, a) => …), which hands you sp as a + /// parameter of a factory the framework invokes at the right time, so this never bites. Resolve singletons / + /// factories (e.g. IHttpClientFactory, IDocumentStore) and open short-lived units per read (§9). + /// + public IServiceProvider ServiceProvider => IsActive + ? _serviceProvider() + : throw new InvalidOperationException( + "ServiceBackedRuleContext.ServiceProvider is only available at recompute time (after the host has " + + "started). Read it inside the rule's factory — e.g. builder.ServiceBacked((sp, a) => …) — not in the " + + "authoring method body, which runs eagerly before the container exists."); +} diff --git a/src/Cocoar.Configuration/Fluent/ProviderRuleBuilder.cs b/src/Cocoar.Configuration/Fluent/ProviderRuleBuilder.cs index b221db6..9850cf4 100644 --- a/src/Cocoar.Configuration/Fluent/ProviderRuleBuilder.cs +++ b/src/Cocoar.Configuration/Fluent/ProviderRuleBuilder.cs @@ -36,7 +36,8 @@ public ConfigRule Build() Required: IsRequired, UseWhen: UseWhen, Name: Name, - TenantScoped: IsTenantScoped) + TenantScoped: IsTenantScoped, + ActivationGate: ActivationGate) .WithMount(MountPath) .WithSelect(SelectPath); diff --git a/src/Cocoar.Configuration/Fluent/RuleBuilderBase.cs b/src/Cocoar.Configuration/Fluent/RuleBuilderBase.cs index 6e312f7..10ff847 100644 --- a/src/Cocoar.Configuration/Fluent/RuleBuilderBase.cs +++ b/src/Cocoar.Configuration/Fluent/RuleBuilderBase.cs @@ -15,6 +15,14 @@ public abstract class RuleBuilderBase protected bool IsTenantScoped { get; set; } protected Func? UseWhen { get; set; } + + /// + /// A system-level activation gate, set by the DI/HTTP service-backed (Layer-2, ADR-006) overloads. + /// Evaluated independently of and the marker, so a later + /// user cannot remove it; the rule is skipped until the gate returns true. + /// + internal Func? ActivationGate { get; private set; } + protected Type? ConcreteType { get; set; } protected string? MountPath { get; set; } protected string? SelectPath { get; set; } @@ -62,6 +70,20 @@ public TBuilder TenantScoped() return (TBuilder)this; } + /// + /// Attaches a system-level activation gate (composed with AND), evaluated independently of + /// so a later user .When() cannot clobber it. The service-backed overloads (FromStorage, + /// FromHttp((sp,a)=>…)) — and third-party ones — use it to keep a Layer-2 rule dormant until the + /// container is built: .WithActivationGate(_ => context.IsActive) (ADR-006). + /// + public TBuilder WithActivationGate(Func gate) + { + ArgumentNullException.ThrowIfNull(gate); + var existing = ActivationGate; + ActivationGate = existing is null ? gate : a => existing(a) && gate(a); + return (TBuilder)this; + } + /// /// Sets the JSON mount path used when merging this rule's output into the target configuration type. /// Values from this rule are nested under the given path before deserialization. diff --git a/src/Cocoar.Configuration/Fluent/ServiceBackedProviderBuilder.cs b/src/Cocoar.Configuration/Fluent/ServiceBackedProviderBuilder.cs new file mode 100644 index 0000000..97d3f38 --- /dev/null +++ b/src/Cocoar.Configuration/Fluent/ServiceBackedProviderBuilder.cs @@ -0,0 +1,117 @@ +using System.Text.Json; +using Cocoar.Configuration.Core; +using Cocoar.Configuration.Providers; +using Cocoar.Configuration.Providers.Abstractions; +using Cocoar.Configuration.Testing; + +namespace Cocoar.Configuration.Fluent; + +/// +/// The builder for service-backed (Layer-2, ADR-006) rules, returned by +/// . It inherits every provider method of +/// (so a non-DI rule like FromFile can still be placed in Layer 2) +/// and additionally carries the that (sp, a) => … overloads read. +/// +/// Because those overloads (FromStorage, FromHttp((sp,a)=>…), and any third-party one) target +/// this type, using them inside the Layer-1 UseConfiguration — which yields a plain +/// — is a compile error, not a runtime fault. A third-party provider +/// is made service-backable simply by authoring an extension method on this type (and giving its provider options +/// a slot for the resolved artifact); whether to do so is entirely the provider author's choice. +/// +/// +/// The configuration type this rule will populate. +public sealed class ServiceBackedProviderBuilder : TypedProviderBuilder where T : class +{ + internal ServiceBackedProviderBuilder(ServiceBackedRuleContext context) => Context = context; + + /// + /// The container hook a service-backed rule factory resolves application services from. Prefer + /// over reading this directly — it hands you the + /// provider as a (sp, accessor) => … factory invoked at the right time. + /// is only valid at recompute time and throws if read in the (eager) authoring body. + /// + public ServiceBackedRuleContext Context { get; } + + /// + /// Builds a service-backed (Layer-2, ADR-006) provider rule from factories that receive the application + /// . This is the safe, ergonomic way to author a custom service-backed + /// provider overload: gets sp as a parameter invoked lazily at + /// recompute time (so it can never be read too early), and the rule is gated automatically (dormant until the + /// container is built). sp is the root provider — resolve singletons / factories and open short-lived + /// units per read (§9). + /// + /// + /// + /// public static ProviderRuleBuilder<MyProvider, MyOptions, MyQuery> FromMyDb<T>( + /// this ServiceBackedProviderBuilder<T> builder, + /// Func<IServiceProvider, IConfigurationAccessor, MyBackend> backendFactory, string key) where T : class + /// => builder.ServiceBacked<MyProvider, MyOptions, MyQuery>( + /// (sp, a) => new MyOptions(backendFactory(sp, a)), + /// _ => new MyQuery(key)); + /// + /// + /// Builds the provider options from the root + /// and the current (its Tenant is set in a tenant pipeline). + /// Builds the per-rule query options. + public ProviderRuleBuilder ServiceBacked( + Func optionsFactory, + Func queryFactory) + where TProvider : ConfigurationProvider + where TOptions : IProviderConfiguration + where TQuery : IProviderQuery + { + ArgumentNullException.ThrowIfNull(optionsFactory); + ArgumentNullException.ThrowIfNull(queryFactory); + + var context = Context; + var rule = new ProviderRuleBuilder( + accessor => optionsFactory(context.ServiceProvider, accessor), // sp read here = recompute time, never eager + queryFactory, + typeof(T)); + + return rule.WithActivationGate(_ => context.IsActive); // dormant until the container is built + } + + /// + /// Service-backed (Layer-2, ADR-006) rule that derives the whole configuration object from a single DI + /// service — Cocoar's equivalent of Microsoft's services.Configure<TDep>((opts, dep) => …) / + /// an IConfigureOptions<T> with an injected dependency. is resolved + /// from the container at recompute time (after host start) and handed to , which + /// returns the config value. No custom provider needed; the rule is dormant until the container is built and is + /// the natural target when migrating Configure<TDep>/IConfigureOptions<T>. + /// + /// Synchronous / in-memory by nature (it snapshots once per recompute, no change detection). For I/O-bound + /// sources (DB, HTTP) use an async provider instead — FromStorage, FromHttp((sp,a)=>…), or a + /// custom provider — rather than blocking here. + /// + /// + /// + /// + /// rules.For<AppSettings>().FromService<AppSettingsService>(s => s.Settings) + /// + /// + /// The DI service to resolve and project from. + /// Maps the resolved service to the configuration value. + public ProviderRuleBuilder + FromService(Func projection) + where TService : notnull + { + ArgumentNullException.ThrowIfNull(projection); + + return ServiceBacked( + (sp, _) => + { + // BCL IServiceProvider.GetService(Type) keeps the No-DI core free of a Microsoft.Extensions.DI dependency. + if (sp.GetService(typeof(TService)) is not TService service) + { + throw new InvalidOperationException( + $"Service '{typeof(TService).Name}' is not registered; cannot build configuration " + + $"'{typeof(T).Name}' via FromService<{typeof(TService).Name}>()."); + } + + var json = JsonSerializer.SerializeToElement(projection(service), CocoarTestConfiguration.Current?.SerializerOptions); + return new StaticJsonProviderOptions(json); + }, + _ => new StaticJsonProviderQueryOptions()); + } +} diff --git a/src/Cocoar.Configuration/Fluent/ServiceBackedRulesBuilder.cs b/src/Cocoar.Configuration/Fluent/ServiceBackedRulesBuilder.cs new file mode 100644 index 0000000..62ff42f --- /dev/null +++ b/src/Cocoar.Configuration/Fluent/ServiceBackedRulesBuilder.cs @@ -0,0 +1,21 @@ +using Cocoar.Configuration.Core; + +namespace Cocoar.Configuration.Fluent; + +/// +/// The rule-list builder for UseServiceBackedConfiguration (Layer 2, ADR-006). Start with +/// to author a service-backed rule whose factories receive the application +/// . Mirrors but yields the DI-aware +/// . +/// +public sealed class ServiceBackedRulesBuilder +{ + private readonly ServiceBackedRuleContext _context; + + internal ServiceBackedRulesBuilder(ServiceBackedRuleContext context) + => _context = context ?? throw new ArgumentNullException(nameof(context)); + + /// Start a type-safe service-backed rule for configuration type . + /// The configuration type this rule will populate. + public ServiceBackedProviderBuilder For() where T : class => new(_context); +} diff --git a/src/Cocoar.Configuration/Rules/ConfigRuleOptions.cs b/src/Cocoar.Configuration/Rules/ConfigRuleOptions.cs index a22f24b..359b196 100644 --- a/src/Cocoar.Configuration/Rules/ConfigRuleOptions.cs +++ b/src/Cocoar.Configuration/Rules/ConfigRuleOptions.cs @@ -8,7 +8,8 @@ public sealed record ConfigRuleOptions( string? MountPath = null, string? SelectPath = null, string? Name = null, - bool TenantScoped = false) + bool TenantScoped = false, + Func? ActivationGate = null) { public ConfigRuleOptions WithMount(string? mountPath) => this with { MountPath = string.IsNullOrWhiteSpace(mountPath) ? null : mountPath.Trim() }; diff --git a/src/Cocoar.Configuration/Rules/RuleManager.cs b/src/Cocoar.Configuration/Rules/RuleManager.cs index 6b03496..5f95a95 100644 --- a/src/Cocoar.Configuration/Rules/RuleManager.cs +++ b/src/Cocoar.Configuration/Rules/RuleManager.cs @@ -141,6 +141,14 @@ private bool ShouldSkip(IConfigurationAccessor accessor) return MarkSkipped(); } + // A service-backed (Layer-2, ADR-006) rule stays dormant until the application container is built and the + // activation recompute runs. Enforced via a dedicated gate (not the user .When predicate) so it holds + // regardless of fluent ordering — a later .When() cannot clobber it (mirrors the .TenantScoped() marker). + if (_rule.Options?.ActivationGate is { } activationGate && !activationGate.Invoke(accessor)) + { + return MarkSkipped(); + } + if (_rule.Options?.UseWhen == null) { return false; diff --git a/src/Directory.Packages.props b/src/Directory.Packages.props index 5f7bdb9..4425be1 100644 --- a/src/Directory.Packages.props +++ b/src/Directory.Packages.props @@ -13,6 +13,9 @@ + + + diff --git a/src/Examples/ServiceBackedConfig/Program.cs b/src/Examples/ServiceBackedConfig/Program.cs new file mode 100644 index 0000000..9e17094 --- /dev/null +++ b/src/Examples/ServiceBackedConfig/Program.cs @@ -0,0 +1,95 @@ +using System.Text; +using Cocoar.Configuration.Core; +using Cocoar.Configuration.DI; +using Cocoar.Configuration.Providers; +using Cocoar.Configuration.Reactive; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; + +// ADR-006 "service-backed" (Layer-2) configuration, end to end. +// +// Layer 1 (UseConfiguration) is eager and DI-free: a bootstrap default available before the container exists. +// Layer 2 (UseServiceBackedConfiguration) is lazy and container-owned: its FromStorage factory resolves a +// DI-managed "store" (here an in-memory stand-in for Marten/EF) and overrides the base. Layer 2 activates on +// host start via a recompute — so a reactive view obtained BEFORE the host runs still receives the upgrade. + +var builder = Host.CreateApplicationBuilder(args); + +// A DI-managed singleton standing in for a real document store (Marten IDocumentStore / EF IDbContextFactory). +builder.Services.AddSingleton(); + +builder.Services.AddCocoarConfiguration(c => c + // Layer 1 — eager bootstrap default (no IServiceProvider). + .UseConfiguration(rules => + [ + rules.For().FromStaticJson("""{ "Banner": "bootstrap default", "MaxItems": 10 }"""), + ]) + // Layer 2 — container-owned: the factory receives the IServiceProvider and resolves the store. + .UseServiceBackedConfiguration(rules => + [ + rules.For().FromStorage((sp, _) => sp.GetRequiredService().Backend), + ]) + .UseDebounce(25)); + +using var host = builder.Build(); +var config = host.Services.GetRequiredService(); + +// Subscribe BEFORE the host starts — like wiring a Serilog level switch during bootstrap. +var observer = new ConsoleObserver(); +using var subscription = config.GetReactiveConfig().Subscribe(observer); + +Console.WriteLine("=== Before host start (Layer 1 only) ==="); +Console.WriteLine($" GetConfig: {config.GetConfig()}"); + +Console.WriteLine("\n=== Starting host (activates Layer 2) ==="); +await host.StartAsync(); + +Console.WriteLine("\n=== After host start (Layer 2 merged over Layer 1) ==="); +Console.WriteLine($" GetConfig: {config.GetConfig()}"); + +await host.StopAsync(); + +Console.WriteLine("\nNote how the reactive subscription — obtained pre-container — received BOTH the"); +Console.WriteLine("Layer-1 bootstrap value and the Layer-2 upgrade, on the same live view."); + +// ----- types ----- + +public sealed record FeatureConfig +{ + public string Banner { get; init; } = ""; + public int MaxItems { get; init; } + + public override string ToString() => $"Banner='{Banner}', MaxItems={MaxItems}"; +} + +/// A DI-managed store; in a real app this wraps Marten's IDocumentStore or an EF IDbContextFactory. +public interface IFeatureStore +{ + IStorageBackend Backend { get; } +} + +internal sealed class InMemoryFeatureStore : IFeatureStore +{ + // The "row" the store would load — overrides Banner, inherits MaxItems from the Layer-1 base (sparse overlay). + public IStorageBackend Backend { get; } = new SeededBackend("""{ "Banner": "live from the database" }"""); +} + +internal sealed class SeededBackend(string json) : IStorageBackend +{ + private byte[] _data = Encoding.UTF8.GetBytes(json); + + public Task ReadAsync(string key, CancellationToken ct = default) => Task.FromResult(_data); + + public Task WriteAsync(string key, byte[] data, CancellationToken ct = default) + { + _data = data; + return Task.CompletedTask; + } +} + +internal sealed class ConsoleObserver : IObserver +{ + public void OnNext(FeatureConfig value) => Console.WriteLine($" [reactive] -> {value}"); + public void OnError(Exception error) { } + public void OnCompleted() { } +} diff --git a/src/Examples/ServiceBackedConfig/ServiceBackedConfig.csproj b/src/Examples/ServiceBackedConfig/ServiceBackedConfig.csproj new file mode 100644 index 0000000..811f4f4 --- /dev/null +++ b/src/Examples/ServiceBackedConfig/ServiceBackedConfig.csproj @@ -0,0 +1,17 @@ + + + Exe + net9.0 + enable + enable + true + Examples.ServiceBackedConfig + + + + + + + + + diff --git a/src/tests/Cocoar.Configuration.AspNetCore.Tests/TenantReactiveConfigTests.cs b/src/tests/Cocoar.Configuration.AspNetCore.Tests/TenantReactiveConfigTests.cs new file mode 100644 index 0000000..e39681d --- /dev/null +++ b/src/tests/Cocoar.Configuration.AspNetCore.Tests/TenantReactiveConfigTests.cs @@ -0,0 +1,93 @@ +using Cocoar.Configuration.AspNetCore; +using Cocoar.Configuration.Core; +using Cocoar.Configuration.DI; +using Cocoar.Configuration.Providers; // FromStaticJson / FromStatic +using Cocoar.Configuration.Reactive; // IReactiveConfig +using Microsoft.Extensions.DependencyInjection; +using Xunit; + +namespace Cocoar.Configuration.AspNetCore.Tests; + +public sealed record TenantCfg +{ + public string Region { get; init; } = "base"; +} + +/// A test whose tenant is set per scope. +internal sealed class MutableTenantContext : ITenantContext +{ + public string? Current { get; set; } +} + +/// +/// ADR-006 §11: the scoped adapter resolves the current request's tenant +/// config, while the singleton stays the global view (the §11 trap). +/// +public class TenantReactiveConfigTests +{ + private static ServiceProvider BuildProvider() + { + var services = new ServiceCollection(); + services.AddCocoarConfiguration(c => c + .UseConfiguration(rules => + [ + rules.For().FromStaticJson("""{ "Region": "base" }"""), + rules.For().FromStatic(a => new TenantCfg { Region = $"region-{a.Tenant}" }).TenantScoped(), + ]) + .UseDebounce(25)); + + services.AddScoped(); + services.AddScoped(sp => sp.GetRequiredService()); + services.AddCocoarTenantReactiveConfig(); + + return services.BuildServiceProvider(); + } + + [Fact] + public async Task ScopedAdapter_BindsToCurrentRequestTenant() + { + await using var sp = BuildProvider(); + var mgr = sp.GetRequiredService(); + var tenants = (ITenantConfigurationAccessor)mgr; + await tenants.InitializeTenantAsync("acme"); + await tenants.InitializeTenantAsync("globex"); + + using (var scope = sp.CreateScope()) + { + scope.ServiceProvider.GetRequiredService().Current = "acme"; + var cfg = scope.ServiceProvider.GetRequiredService>(); + Assert.Equal("region-acme", cfg.CurrentValue.Region); + } + + using (var scope = sp.CreateScope()) + { + scope.ServiceProvider.GetRequiredService().Current = "globex"; + var cfg = scope.ServiceProvider.GetRequiredService>(); + Assert.Equal("region-globex", cfg.CurrentValue.Region); + } + } + + [Fact] + public async Task SingletonIReactiveConfig_StaysGlobal_NotReRegisteredAsScoped() + { + await using var sp = BuildProvider(); + + // The §11 trap: IReactiveConfig is still the singleton, global (tenant-agnostic) view — resolvable + // from the root and showing the base value, untouched by the tenant adapter registration. + var global = sp.GetRequiredService>(); + Assert.Equal("base", global.CurrentValue.Region); + } + + [Fact] + public async Task ScopedAdapter_WithoutTenant_Throws() + { + await using var sp = BuildProvider(); + + using var scope = sp.CreateScope(); + scope.ServiceProvider.GetRequiredService().Current = null; + var cfg = scope.ServiceProvider.GetRequiredService>(); + + // Lazy: the missing tenant surfaces on first use, not at construction. + Assert.Throws(() => _ = cfg.CurrentValue); + } +} diff --git a/src/tests/Cocoar.Configuration.ServiceBacked.Tests/Cocoar.Configuration.ServiceBacked.Tests.csproj b/src/tests/Cocoar.Configuration.ServiceBacked.Tests/Cocoar.Configuration.ServiceBacked.Tests.csproj new file mode 100644 index 0000000..0b5035a --- /dev/null +++ b/src/tests/Cocoar.Configuration.ServiceBacked.Tests/Cocoar.Configuration.ServiceBacked.Tests.csproj @@ -0,0 +1,36 @@ + + + + net9.0 + enable + enable + true + + + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + + + + + diff --git a/src/tests/Cocoar.Configuration.ServiceBacked.Tests/ServiceBackedActivationTests.cs b/src/tests/Cocoar.Configuration.ServiceBacked.Tests/ServiceBackedActivationTests.cs new file mode 100644 index 0000000..4a92ed2 --- /dev/null +++ b/src/tests/Cocoar.Configuration.ServiceBacked.Tests/ServiceBackedActivationTests.cs @@ -0,0 +1,116 @@ +using Cocoar.Configuration.Core; +using Cocoar.Configuration.DI; // FromStorage, UseServiceBackedConfiguration, ActivateServiceBackedConfigurationAsync +using Cocoar.Configuration.Providers; // FromStaticJson +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; // IHostedService + +namespace Cocoar.Configuration.ServiceBacked.Tests; + +/// +/// Activation mechanics: manual (non-host) activation, the non-breaking guarantee (a hosted service only when +/// Layer 2 is used), the fluent-order-proof sp gate, and the misuse guardrail. +/// +[Trait("Category", "ServiceBacked")] +[Trait("Type", "Unit")] +public class ServiceBackedActivationTests +{ + [Fact] + public async Task ManualActivation_OnPlainContainer_ActivatesLayer2() + { + var services = new ServiceCollection(); + services.AddCocoarConfiguration(c => c + .UseConfiguration(rules => + [ + rules.For().FromStaticJson("""{ "Value": "base" }"""), + ]) + .UseServiceBackedConfiguration(rules => + [ + rules.For().FromStorage((_, _) => new SeededBackend("""{ "Value": "stored" }""")), + ]) + .UseDebounce(25)); + + await using var sp = services.BuildServiceProvider(); + var mgr = sp.GetRequiredService(); + + // Before activation: dormant → Layer-1 base. + Assert.Equal("base", mgr.GetConfig()!.Value); + + // ActivateAsync awaits the recompute, so the value is ready synchronously after it returns. + await sp.ActivateServiceBackedConfigurationAsync(); + Assert.Equal("stored", mgr.GetConfig()!.Value); + } + + [Fact] + public async Task ManualActivation_IsIdempotent() + { + var services = new ServiceCollection(); + services.AddCocoarConfiguration(c => c + .UseConfiguration(rules => [ rules.For().FromStaticJson("""{ "Value": "base" }""") ]) + .UseServiceBackedConfiguration(rules => + [ + rules.For().FromStorage((_, _) => new SeededBackend("""{ "Value": "stored" }""")), + ]) + .UseDebounce(25)); + + await using var sp = services.BuildServiceProvider(); + var mgr = sp.GetRequiredService(); + + await sp.ActivateServiceBackedConfigurationAsync(); + await sp.ActivateServiceBackedConfigurationAsync(); // second call is a no-op, must not throw or regress + Assert.Equal("stored", mgr.GetConfig()!.Value); + } + + [Fact] + public void Layer1Only_RegistersNoActivationHostedService() + { + var services = new ServiceCollection(); + services.AddCocoarConfiguration(c => c + .UseConfiguration(rules => [ rules.For().FromStaticJson("""{ "Value": "base" }""") ])); + + // Non-breaking rule 3: apps that do not opt into Layer 2 get no hosted service. + Assert.DoesNotContain(services, d => d.ServiceType == typeof(IHostedService)); + } + + [Fact] + public void ServiceBacked_RegistersExactlyOneActivationHostedService() + { + var services = new ServiceCollection(); + services.AddCocoarConfiguration(c => c + .UseConfiguration(rules => [ rules.For().FromStaticJson("""{ "Value": "base" }""") ]) + .UseServiceBackedConfiguration(rules => + [ + rules.For().FromStorage((_, _) => new SeededBackend("{}")), + ])); + + Assert.Single(services, d => d.ServiceType == typeof(IHostedService)); + } + + [Fact] + public async Task ActivationGate_SurvivesATrailingUserWhen_FluentOrderProof() + { + var services = new ServiceCollection(); + services.AddCocoarConfiguration(c => c + .UseConfiguration(rules => [ rules.For().FromStaticJson("""{ "Value": "base" }""") ]) + .UseServiceBackedConfiguration(rules => + [ + // A user .When() AFTER the sp-overload must NOT clobber the activation gate. + rules.For() + .FromStorage((_, _) => new SeededBackend("""{ "Value": "stored" }""")) + .When(_ => true), + ]) + .UseDebounce(25)); + + await using var sp = services.BuildServiceProvider(); + var mgr = sp.GetRequiredService(); + + // Still dormant before activation despite .When(_ => true). + Assert.Equal("base", mgr.GetConfig()!.Value); + + await sp.ActivateServiceBackedConfigurationAsync(); + Assert.Equal("stored", mgr.GetConfig()!.Value); + } + + // NOTE: using FromStorage / FromHttp((sp,a)=>…) outside UseServiceBackedConfiguration is now a COMPILE error + // (those overloads target ServiceBackedProviderBuilder, which UseConfiguration's plain + // TypedProviderBuilder is not) — there is no longer a runtime-throw path to test. +} diff --git a/src/tests/Cocoar.Configuration.ServiceBacked.Tests/ServiceBackedFromServiceTests.cs b/src/tests/Cocoar.Configuration.ServiceBacked.Tests/ServiceBackedFromServiceTests.cs new file mode 100644 index 0000000..0292eda --- /dev/null +++ b/src/tests/Cocoar.Configuration.ServiceBacked.Tests/ServiceBackedFromServiceTests.cs @@ -0,0 +1,73 @@ +using Cocoar.Configuration.Core; +using Cocoar.Configuration.DI; +using Cocoar.Configuration.Providers; // FromStaticJson +using Microsoft.Extensions.DependencyInjection; + +namespace Cocoar.Configuration.ServiceBacked.Tests; + +public sealed record AppCfg +{ + public string Value { get; init; } = ""; +} + +/// A DI service that holds the configuration in memory (e.g. computed/derived at startup). +internal sealed class AppCfgSource +{ + public AppCfg Settings { get; } = new() { Value = "from-service" }; +} + +/// +/// FromService<TService>(s => s.Settings) — derive config from a single DI service (Cocoar's +/// analog of services.Configure<TDep>((opts, dep) => …)). No custom provider needed. +/// +[Trait("Category", "ServiceBacked")] +[Trait("Type", "Unit")] +public class ServiceBackedFromServiceTests +{ + [Fact] + public async Task FromService_ResolvesServiceAndProducesConfig_OnActivation() + { + var services = new ServiceCollection(); + services.AddSingleton(); + services.AddCocoarConfiguration(c => c + .UseConfiguration(rules => [ rules.For().FromStaticJson("""{ "Value": "base" }""") ]) + .UseServiceBackedConfiguration(rules => + [ + // The exact requested shape: TService explicit, the service handed to the lambda. + rules.For().FromService(s => s.Settings), + ]) + .UseDebounce(25)); + + await using var sp = services.BuildServiceProvider(); + var mgr = sp.GetRequiredService(); + + // Dormant before activation → Layer-1 base. + Assert.Equal("base", mgr.GetConfig()!.Value); + + await sp.ActivateServiceBackedConfigurationAsync(); + + // Activated → resolved from the DI service. + Assert.Equal("from-service", mgr.GetConfig()!.Value); + } + + [Fact] + public async Task FromService_UnregisteredService_DoesNotCrashActivation_Layer1Persists() + { + // AppCfgSource is NOT registered → resolution throws at recompute → the recompute rolls back and the + // Layer-1 base persists; activation must not fault. + var services = new ServiceCollection(); + services.AddCocoarConfiguration(c => c + .UseConfiguration(rules => [ rules.For().FromStaticJson("""{ "Value": "base" }""") ]) + .UseServiceBackedConfiguration(rules => + [ + rules.For().FromService(s => s.Settings), + ]) + .UseDebounce(25)); + + await using var sp = services.BuildServiceProvider(); + var mgr = sp.GetRequiredService(); + + await sp.ActivateServiceBackedConfigurationAsync(); // must not throw + Assert.Equal("base", mgr.GetConfig()!.Value); + } +} diff --git a/src/tests/Cocoar.Configuration.ServiceBacked.Tests/ServiceBackedHttpTests.cs b/src/tests/Cocoar.Configuration.ServiceBacked.Tests/ServiceBackedHttpTests.cs new file mode 100644 index 0000000..852dae9 --- /dev/null +++ b/src/tests/Cocoar.Configuration.ServiceBacked.Tests/ServiceBackedHttpTests.cs @@ -0,0 +1,162 @@ +using System.Net; +using Cocoar.Configuration.Core; +using Cocoar.Configuration.DI; +using Cocoar.Configuration.Http; // FromHttp((sp,a)=>HttpClient, ...) +using Cocoar.Configuration.Providers; // FromStaticJson +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; + +namespace Cocoar.Configuration.ServiceBacked.Tests; + +/// +/// ADR-006 headline #1: a global service-backed (Layer-2) HTTP rule sources its HttpClient from the +/// container's IHttpClientFactory, and the value arrives after host start. +/// +[Trait("Category", "ServiceBacked")] +[Trait("Type", "Unit")] +public class ServiceBackedHttpTests +{ + private static HostApplicationBuilder NewHostWithClient(string clientName, StubHttpHandler handler) + { + var builder = Host.CreateApplicationBuilder(); + builder.Services + .AddHttpClient(clientName) + .ConfigurePrimaryHttpMessageHandler(() => handler); + return builder; + } + + [Fact] + public async Task GlobalHttp_ViaHttpClientFactory_LandsAfterHostStart() + { + var handler = new StubHttpHandler("""{ "Value": "from-remote" }"""); + var builder = NewHostWithClient("cocoar-config", handler); + + builder.Services.AddCocoarConfiguration(c => c + .UseConfiguration(rules => + [ + rules.For().FromStaticJson("""{ "Value": "base" }"""), + ]) + .UseServiceBackedConfiguration(rules => + [ + rules.For().FromHttp( + (sp, _) => sp.GetRequiredService().CreateClient("cocoar-config"), + "https://config.example/remote.json"), + ]) + .UseDebounce(25)); + + using var host = builder.Build(); + var mgr = host.Services.GetRequiredService(); + + // Before host start: the sp-gated Layer-2 rule is dormant → the Layer-1 base is visible. + Assert.Equal("base", mgr.GetConfig()!.Value); + + await host.StartAsync(); + + // After host start: Layer 2 activated and merged over Layer 1 — the IHttpClientFactory client was used. + Assert.Equal("from-remote", mgr.GetConfig()!.Value); + Assert.True(handler.CallCount >= 1); + + await host.StopAsync(); + } + + [Fact] + public async Task Layer2OnlyType_IsUnresolvedBeforeStart_ResolvedAfter() + { + var handler = new StubHttpHandler("""{ "Value": "remote-only" }"""); + var builder = NewHostWithClient("cfg", handler); + + builder.Services.AddCocoarConfiguration(c => c + // RemoteConfig has NO Layer-1 rule — it exists only in Layer 2. + .UseServiceBackedConfiguration(rules => + [ + rules.For().FromHttp( + (sp, _) => sp.GetRequiredService().CreateClient("cfg"), + "https://x/remote.json"), + ]) + .UseDebounce(25)); + + using var host = builder.Build(); + var mgr = host.Services.GetRequiredService(); + + // Readiness (ADR-006 §7): a type that exists only in Layer 2 is unresolved before host start — + // TryGetConfig returns false (and GetConfig would throw), matching "unresolved (null)". + Assert.False(mgr.TryGetConfig(out _)); + + await host.StartAsync(); + + Assert.True(mgr.TryGetConfig(out var after)); + Assert.Equal("remote-only", after!.Value); + + await host.StopAsync(); + } + + [Fact] + public async Task FailingOptionalLayer2_RollsBackToLayer1_AndDegradesHealth() + { + // The remote source is down (HTTP 500) → EnsureSuccessStatusCode throws → optional rule degrades. + var handler = new StubHttpHandler(_ => (HttpStatusCode.InternalServerError, "boom")); + var builder = NewHostWithClient("cfg", handler); + + builder.Services.AddCocoarConfiguration(c => c + .UseConfiguration(rules => + [ + rules.For().FromStaticJson("""{ "Value": "base" }"""), + ]) + .UseServiceBackedConfiguration(rules => + [ + rules.For().FromHttp( + (sp, _) => sp.GetRequiredService().CreateClient("cfg"), + "https://x/remote.json"), + ]) + .UseDebounce(25)); + + using var host = builder.Build(); + + // Host start must NOT fault on a remote outage. + await host.StartAsync(); + + var mgr = host.Services.GetRequiredService(); + + // Layer-1 value persists; health is degraded (the failed optional rule is recorded). + Assert.Equal("base", mgr.GetConfig()!.Value); + Assert.False(mgr.IsHealthy); + + await host.StopAsync(); + } + + [Fact] + public async Task Layer2OnlyType_PassesValidateOnBuildAndScopes() + { + // ASP.NET Core's Development default turns on ValidateOnBuild + ValidateScopes. A Layer-2-only type is + // unresolved before host start; this guards that build/validation never invokes the (factory-registered) + // config services, so an all-remote config type does not break host construction. + var handler = new StubHttpHandler("""{ "Value": "remote-only" }"""); + + using var host = new HostBuilder() + .UseDefaultServiceProvider((_, options) => + { + options.ValidateOnBuild = true; + options.ValidateScopes = true; + }) + .ConfigureServices(services => + { + services.AddHttpClient("cfg").ConfigurePrimaryHttpMessageHandler(() => handler); + services.AddCocoarConfiguration(c => c + .UseServiceBackedConfiguration(rules => + [ + rules.For().FromHttp( + (sp, _) => sp.GetRequiredService().CreateClient("cfg"), + "https://x/remote.json"), + ]) + .UseDebounce(25)); + }) + .Build(); // must not throw under ValidateOnBuild + + await host.StartAsync(); + + var mgr = host.Services.GetRequiredService(); + Assert.Equal("remote-only", mgr.GetConfig()!.Value); + + await host.StopAsync(); + } +} diff --git a/src/tests/Cocoar.Configuration.ServiceBacked.Tests/ServiceBackedReactiveTests.cs b/src/tests/Cocoar.Configuration.ServiceBacked.Tests/ServiceBackedReactiveTests.cs new file mode 100644 index 0000000..aa7c33c --- /dev/null +++ b/src/tests/Cocoar.Configuration.ServiceBacked.Tests/ServiceBackedReactiveTests.cs @@ -0,0 +1,58 @@ +using System.Linq; +using Cocoar.Configuration.Core; +using Cocoar.Configuration.DI; +using Cocoar.Configuration.Http; // FromHttp((sp,a)=>HttpClient, ...) +using Cocoar.Configuration.Providers; // FromStaticJson +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; + +namespace Cocoar.Configuration.ServiceBacked.Tests; + +/// +/// ADR-006 §6 (the load-bearing reactive contract): Layer-2 activation is a RECOMPUTE on the existing pipeline, +/// never a rebuild — so a reactive view obtained pre-container (e.g. a Serilog LoggingLevelSwitch +/// wired before the host runs) receives the Layer-2 value when it lands, over the same live view. +/// +[Trait("Category", "ServiceBacked")] +[Trait("Type", "Unit")] +public class ServiceBackedReactiveTests +{ + [Fact] + public async Task PreContainerSubscription_ReceivesLayer2Upgrade_OnSameLiveView() + { + var handler = new StubHttpHandler("""{ "Level": "Debug" }"""); + + var builder = Host.CreateApplicationBuilder(); + builder.Services.AddHttpClient("cfg").ConfigurePrimaryHttpMessageHandler(() => handler); + builder.Services.AddCocoarConfiguration(c => c + .UseConfiguration(rules => + [ + rules.For().FromStaticJson("""{ "Level": "Info" }"""), + ]) + .UseServiceBackedConfiguration(rules => + [ + rules.For().FromHttp( + (sp, _) => sp.GetRequiredService().CreateClient("cfg"), + "https://x/log.json"), + ]) + .UseDebounce(25)); + + using var host = builder.Build(); + var mgr = host.Services.GetRequiredService(); + + // Subscribe BEFORE the host starts — like a logging level switch wired during bootstrap. + var observer = new CollectingObserver(); + using var subscription = mgr.GetReactiveConfig().Subscribe(observer); + + // Replay-1: the immediate emission is the Layer-1 file value. + Assert.Equal(new[] { "Info" }, observer.Snapshot().Select(x => x.Level).ToArray()); + + await host.StartAsync(); + + // The same live view receives the Layer-2 value once activation lands. + await Wait.UntilAsync(() => observer.Snapshot().Any(x => x.Level == "Debug"), "Layer-2 reactive upgrade"); + Assert.Equal(new[] { "Info", "Debug" }, observer.Snapshot().Select(x => x.Level).ToArray()); + + await host.StopAsync(); + } +} diff --git a/src/tests/Cocoar.Configuration.ServiceBacked.Tests/ServiceBackedReviewRegressionTests.cs b/src/tests/Cocoar.Configuration.ServiceBacked.Tests/ServiceBackedReviewRegressionTests.cs new file mode 100644 index 0000000..cad8ea3 --- /dev/null +++ b/src/tests/Cocoar.Configuration.ServiceBacked.Tests/ServiceBackedReviewRegressionTests.cs @@ -0,0 +1,173 @@ +using Cocoar.Configuration.Core; +using Cocoar.Configuration.DI; +using Cocoar.Configuration.Http; // FromHttp((sp,a)=>HttpClient, ...) +using Cocoar.Configuration.Providers; // FromStaticJson +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; + +namespace Cocoar.Configuration.ServiceBacked.Tests; + +/// +/// Regression tests for defects found by the ADR-006 adversarial review: provider-key collision across distinct +/// IHttpClientFactory clients, per-fetch client acquisition (handler rotation), pre-activation tenant recovery, +/// scoped-provider root capture, and concurrent-activation readiness. +/// +[Trait("Category", "ServiceBacked")] +[Trait("Type", "Unit")] +public class ServiceBackedReviewRegressionTests +{ + // Finding 2: two service-backed HTTP rules with DIFFERENT clients but identical poll settings must NOT collapse + // onto one shared provider (ClientFactory is [JsonIgnore]'d, so the key must be null = non-shareable). + [Fact] + public async Task TwoServiceBackedHttpRules_WithDistinctClients_DoNotCollapse() + { + var handlerA = new StubHttpHandler("""{ "Value": "from-A" }"""); + var handlerB = new StubHttpHandler("""{ "Value": "from-B" }"""); + + var builder = Host.CreateApplicationBuilder(); + builder.Services.AddHttpClient("A").ConfigurePrimaryHttpMessageHandler(() => handlerA); + builder.Services.AddHttpClient("B").ConfigurePrimaryHttpMessageHandler(() => handlerB); + builder.Services.AddCocoarConfiguration(c => c + .UseServiceBackedConfiguration(rules => + [ + rules.For().FromHttp( + (sp, _) => sp.GetRequiredService().CreateClient("A"), "https://x/a.json"), + rules.For().FromHttp( + (sp, _) => sp.GetRequiredService().CreateClient("B"), "https://x/b.json"), + ]) + .UseDebounce(25)); + + using var host = builder.Build(); + await host.StartAsync(); + var mgr = host.Services.GetRequiredService(); + + Assert.Equal("from-A", mgr.GetConfig()!.Value); + Assert.Equal("from-B", mgr.GetConfig()!.Value); + // Both clients were actually used — proves the providers did not collapse (handlerB.CallCount would be 0). + Assert.True(handlerA.CallCount >= 1); + Assert.True(handlerB.CallCount >= 1); + + await host.StopAsync(); + } + + // Finding 8: the IHttpClientFactory factory is invoked per fetch (so handler rotation can apply), not cached once. + [Fact] + public async Task ServiceBackedHttp_AcquiresClientPerFetch() + { + var handler = new StubHttpHandler("""{ "Value": "v" }"""); + var factoryCalls = 0; + + var services = new ServiceCollection(); + services.AddHttpClient("cfg").ConfigurePrimaryHttpMessageHandler(() => handler); + services.AddCocoarConfiguration(c => c + .UseServiceBackedConfiguration(rules => + [ + rules.For().FromHttp( + (sp, _) => + { + Interlocked.Increment(ref factoryCalls); + return sp.GetRequiredService().CreateClient("cfg"); + }, + "https://x/r.json", pollInterval: TimeSpan.FromMilliseconds(80)), + ]) + .UseDebounce(25)); + + await using var sp = services.BuildServiceProvider(); + await sp.ActivateServiceBackedConfigurationAsync(); + + // Activation fetch (1) + subsequent polls (2, 3, …) each re-acquire the client. + await Wait.UntilAsync(() => Volatile.Read(ref factoryCalls) >= 3, "client factory invoked per fetch"); + Assert.True(Volatile.Read(ref factoryCalls) >= 3); + } + + // Findings 4/5/7: a tenant initialized BEFORE activation must recover its service-backed value once activated + // (the activation fan-out recomputes already-initialized tenant pipelines). + [Fact] + public async Task TenantInitializedBeforeActivation_RecoversServiceBackedValue_AfterActivation() + { + var store = new FakeDocumentStore(); + + var services = new ServiceCollection(); + services.AddSingleton(store); + services.AddCocoarConfiguration(c => c + .UseConfiguration(rules => [ rules.For().FromStaticJson("""{ "Db": "base" }""") ]) + .UseServiceBackedConfiguration(rules => + [ + rules.For() + .FromStorage((sp, a) => sp.GetRequiredService().BackendFor(a.Tenant)) + .TenantScoped(), + ]) + .UseDebounce(25)); + + await using var sp = services.BuildServiceProvider(); + var mgr = sp.GetRequiredService(); + var tenants = (ITenantConfigurationAccessor)mgr; + + await tenants.InitializeTenantAsync("acme"); + Assert.Equal("base", mgr.GetConfigForTenant("acme")!.Db); // dormant before activation + + await sp.ActivateServiceBackedConfigurationAsync(); + + // The activation awaited the tenant fan-out, so the value is committed synchronously after it returns. + Assert.Equal("db-for-acme", mgr.GetConfigForTenant("acme")!.Db); + Assert.Contains("acme", store.RequestedTenants); + } + + // Finding 6: activating from a SCOPED provider must capture the root (via RootServiceProviderAccessor); after + // the scope disposes, later polls must still resolve services and keep landing values (no ObjectDisposedException). + [Fact] + public async Task ManualActivation_FromScopedProvider_CapturesRoot_SurvivesScopeDisposal() + { + var handler = new StubHttpHandler("""{ "Value": "polled" }"""); + + var services = new ServiceCollection(); + services.AddHttpClient("cfg").ConfigurePrimaryHttpMessageHandler(() => handler); + services.AddCocoarConfiguration(c => c + .UseServiceBackedConfiguration(rules => + [ + rules.For().FromHttp( + (sp, _) => sp.GetRequiredService().CreateClient("cfg"), + "https://x/r.json", pollInterval: TimeSpan.FromMilliseconds(80)), + ]) + .UseDebounce(25)); + + await using var sp = services.BuildServiceProvider(); + var mgr = sp.GetRequiredService(); + + using (var scope = sp.CreateScope()) + { + await scope.ServiceProvider.ActivateServiceBackedConfigurationAsync(); + } // scope disposed — a captured scope would now fault every poll's GetRequiredService() + + Assert.Equal("polled", mgr.GetConfig()!.Value); + + // A poll AFTER scope disposal must still reach the handler — proves the holder captured root, not the scope. + var callsAfterActivation = handler.CallCount; + await Wait.UntilAsync(() => handler.CallCount > callsAfterActivation, "a poll succeeds after scope disposal"); + Assert.True(mgr.IsHealthy); + } + + // Findings 1/3: two concurrent activations both await the SAME activation recompute and observe Layer 2 + // committed (the CAS loser does not return early before the recompute completes). + [Fact] + public async Task ConcurrentActivations_BothObserveCommittedLayer2() + { + var services = new ServiceCollection(); + services.AddCocoarConfiguration(c => c + .UseConfiguration(rules => [ rules.For().FromStaticJson("""{ "Value": "base" }""") ]) + .UseServiceBackedConfiguration(rules => + [ + rules.For().FromStorage((_, _) => new SeededBackend("""{ "Value": "stored" }""")), + ]) + .UseDebounce(25)); + + await using var sp = services.BuildServiceProvider(); + var mgr = sp.GetRequiredService(); + + await Task.WhenAll( + sp.ActivateServiceBackedConfigurationAsync(), + sp.ActivateServiceBackedConfigurationAsync()); + + Assert.Equal("stored", mgr.GetConfig()!.Value); + } +} diff --git a/src/tests/Cocoar.Configuration.ServiceBacked.Tests/ServiceBackedStorageTests.cs b/src/tests/Cocoar.Configuration.ServiceBacked.Tests/ServiceBackedStorageTests.cs new file mode 100644 index 0000000..66d1685 --- /dev/null +++ b/src/tests/Cocoar.Configuration.ServiceBacked.Tests/ServiceBackedStorageTests.cs @@ -0,0 +1,94 @@ +using Cocoar.Configuration.Core; +using Cocoar.Configuration.DI; // FromStorage, UseServiceBackedConfiguration +using Cocoar.Configuration.Providers; // FromStaticJson +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; + +namespace Cocoar.Configuration.ServiceBacked.Tests; + +/// +/// ADR-006 headline #2: FromStorage((sp,a)=>…).TenantScoped() — a DB-backed (Marten-style) source per +/// tenant. Proves the tenant gate and the sp gate compose: the rule runs only inside a tenant pipeline, post +/// host start, sourcing a backend from the DI-managed store keyed by the tenant. +/// +[Trait("Category", "ServiceBacked")] +[Trait("Type", "Unit")] +public class ServiceBackedStorageTests +{ + [Fact] + public async Task MartenPerTenant_ComposesTenantGateAndServiceProviderGate() + { + var store = new FakeDocumentStore(); + + var builder = Host.CreateApplicationBuilder(); + builder.Services.AddSingleton(store); + builder.Services.AddCocoarConfiguration(c => c + .UseConfiguration(rules => + [ + // A non-tenant global base so the type stays injectable and the global pipeline has a value. + rules.For().FromStaticJson("""{ "Db": "base" }"""), + ]) + .UseServiceBackedConfiguration(rules => + [ + rules.For() + .FromStorage((sp, a) => sp.GetRequiredService().BackendFor(a.Tenant)) + .TenantScoped(), + ]) + .UseDebounce(25)); + + using var host = builder.Build(); + await host.StartAsync(); + + var mgr = host.Services.GetRequiredService(); + var tenants = (ITenantConfigurationAccessor)mgr; + + await tenants.InitializeTenantAsync("acme"); + await tenants.InitializeTenantAsync("globex"); + + await Wait.UntilAsync(() => mgr.GetConfigForTenant("acme")?.Db == "db-for-acme", "acme tenant value"); + await Wait.UntilAsync(() => mgr.GetConfigForTenant("globex")?.Db == "db-for-globex", "globex tenant value"); + + // Each tenant got its OWN backend (the sp + a.Tenant were both used). + Assert.Equal("db-for-acme", mgr.GetConfigForTenant("acme")!.Db); + Assert.Equal("db-for-globex", mgr.GetConfigForTenant("globex")!.Db); + Assert.Contains("acme", store.RequestedTenants); + Assert.Contains("globex", store.RequestedTenants); + + // The global (tenant-agnostic) pipeline skipped the tenant-scoped Layer-2 rule → only the base shows. + Assert.Equal("base", mgr.GetConfig()!.Db); + Assert.DoesNotContain("", store.RequestedTenants); // global pipeline never invoked the factory + + await host.StopAsync(); + } + + [Fact] + public async Task TenantInitializedBeforeActivation_SeesLayer1Base_NotServiceBackedValue() + { + var store = new FakeDocumentStore(); + + // A plain container that is never activated stands in for "before host start": the sp is never published. + var services = new ServiceCollection(); + services.AddSingleton(store); + services.AddCocoarConfiguration(c => c + .UseConfiguration(rules => + [ + rules.For().FromStaticJson("""{ "Db": "base" }"""), + ]) + .UseServiceBackedConfiguration(rules => + [ + rules.For() + .FromStorage((sp, a) => sp.GetRequiredService().BackendFor(a.Tenant)) + .TenantScoped(), + ]) + .UseDebounce(25)); + + await using var sp = services.BuildServiceProvider(); + var mgr = sp.GetRequiredService(); + var tenants = (ITenantConfigurationAccessor)mgr; + + // The sp gate keeps Layer 2 dormant, so the tenant sees the Layer-1 base and the store is never touched. + await tenants.InitializeTenantAsync("acme"); + Assert.Equal("base", mgr.GetConfigForTenant("acme")!.Db); + Assert.Empty(store.RequestedTenants); + } +} diff --git a/src/tests/Cocoar.Configuration.ServiceBacked.Tests/TestSupport.cs b/src/tests/Cocoar.Configuration.ServiceBacked.Tests/TestSupport.cs new file mode 100644 index 0000000..11a26c1 --- /dev/null +++ b/src/tests/Cocoar.Configuration.ServiceBacked.Tests/TestSupport.cs @@ -0,0 +1,162 @@ +using System.Collections.Concurrent; +using System.Diagnostics; +using System.Net; +using System.Text; +using Cocoar.Configuration.Providers; // IStorageBackend + +namespace Cocoar.Configuration.ServiceBacked.Tests; + +// ===== Shared config types ===== + +public sealed record LogConfig +{ + public string Level { get; init; } = "Info"; +} + +public sealed record RemoteConfig +{ + public string Value { get; init; } = ""; +} + +public sealed record TenantSettings +{ + public string Db { get; init; } = ""; +} + +// Two distinct types so two service-backed HTTP rules use two distinct providers/clients. +public sealed record ConfigA +{ + public string Value { get; init; } = ""; +} + +public sealed record ConfigB +{ + public string Value { get; init; } = ""; +} + +// ===== HTTP test double: a primary handler returning a fixed body, used to back an IHttpClientFactory client ===== + +internal sealed class StubHttpHandler : HttpMessageHandler +{ + private readonly Func _respond; + private int _callCount; + + public StubHttpHandler(string body) + : this(_ => (HttpStatusCode.OK, body)) { } + + public StubHttpHandler(Func respond) + { + _respond = respond; + } + + public int CallCount => Volatile.Read(ref _callCount); + + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + Interlocked.Increment(ref _callCount); + var (status, body) = _respond(request); + return Task.FromResult(new HttpResponseMessage(status) + { + Content = new StringContent(body, Encoding.UTF8, "application/json"), + }); + } +} + +// ===== Storage test doubles ===== + +/// An in-memory seeded with a fixed JSON body (read-only for tests). +internal sealed class SeededBackend : IStorageBackend +{ + private byte[]? _data; + + public SeededBackend(string json) => _data = Encoding.UTF8.GetBytes(json); + + public Task ReadAsync(string key, CancellationToken ct = default) => Task.FromResult(_data); + + public Task WriteAsync(string key, byte[] data, CancellationToken ct = default) + { + _data = data; + return Task.CompletedTask; + } +} + +/// +/// Stands in for a DI-managed document store (Marten IDocumentStore): a singleton that hands out a +/// per-tenant backend. Records the tenants it was asked for, so a test can prove the sp was actually used. +/// +internal interface IFakeDocumentStore +{ + IStorageBackend BackendFor(string? tenant); + IReadOnlyCollection RequestedTenants { get; } +} + +internal sealed class FakeDocumentStore : IFakeDocumentStore +{ + private readonly ConcurrentDictionary _backends = new(); + private readonly ConcurrentDictionary _requested = new(); + + public IStorageBackend BackendFor(string? tenant) + { + var key = tenant ?? ""; + _requested.TryAdd(key, 0); + return _backends.GetOrAdd(key, t => new SeededBackend($$"""{ "Db": "db-for-{{t}}" }""")); + } + + public IReadOnlyCollection RequestedTenants => _requested.Keys.ToArray(); +} + +// ===== Reactive observer (BCL-only; avoids a System.Reactive test dependency) ===== + +internal sealed class CollectingObserver : IObserver +{ + private readonly List _items = new(); + private readonly object _gate = new(); + + public void OnNext(T value) + { + lock (_gate) + { + _items.Add(value); + } + } + + public void OnError(Exception error) { } + + public void OnCompleted() { } + + public IReadOnlyList Snapshot() + { + lock (_gate) + { + return _items.ToArray(); + } + } +} + +// ===== Active-wait helper (no Thread.Sleep) ===== + +internal static class Wait +{ + public static async Task UntilAsync(Func condition, string description, int timeoutMs = 15000, int pollMs = 25) + { + var stopwatch = Stopwatch.StartNew(); + while (stopwatch.ElapsedMilliseconds < timeoutMs) + { + try + { + if (condition()) + { + return; + } + } + catch + { + // condition touched not-yet-ready state — treat as "not met yet" + } + + await Task.Delay(pollMs); + } + + throw new TimeoutException($"Timeout waiting for {description} after {timeoutMs}ms"); + } +} diff --git a/src/tests/Cocoar.Configuration.ServiceBacked.Tests/ThirdPartyServiceBackedProviderTests.cs b/src/tests/Cocoar.Configuration.ServiceBacked.Tests/ThirdPartyServiceBackedProviderTests.cs new file mode 100644 index 0000000..e5f6f7c --- /dev/null +++ b/src/tests/Cocoar.Configuration.ServiceBacked.Tests/ThirdPartyServiceBackedProviderTests.cs @@ -0,0 +1,132 @@ +using System.Text; +using Cocoar.Configuration.Core; +using Cocoar.Configuration.DI; +using Cocoar.Configuration.Fluent; +using Cocoar.Configuration.Providers; // FromStaticJson +using Cocoar.Configuration.Providers.Abstractions; +using Microsoft.Extensions.DependencyInjection; + +namespace Cocoar.Configuration.ServiceBacked.Tests; + +// ============================================================================================================ +// This whole file simulates a THIRD-PARTY provider package: a brand-new ConfigurationProvider<,> plus its own +// service-backed `(sp, a) => …` fluent overload — authored entirely against the PUBLIC surface (no +// InternalsVisibleTo). It proves a contributor can make their own provider service-backable; whether to do so is +// entirely the provider author's choice. +// ============================================================================================================ + +/// A trivial third-party provider that emits whatever JSON its options resolve. +public sealed class InlineProvider(InlineOptions options) + : ConfigurationProvider(options) +{ + public override Task FetchConfigurationBytesAsync(InlineQuery query, CancellationToken ct = default) + => Task.FromResult(Encoding.UTF8.GetBytes(ProviderOptions.Json())); + + public override IObservable ChangesAsBytes(InlineQuery query) => NeverObservable.Instance; +} + +public sealed class InlineOptions(Func json) : IProviderConfiguration +{ + /// The JSON to emit — resolved lazily (so it can read a DI service at fetch time). + public Func Json { get; } = json; + + // Carries a closure over the IServiceProvider -> not shareable across rules (mirrors the HTTP/LocalStorage rule). + public string? GenerateProviderKey() => null; +} + +public sealed class InlineQuery : IProviderQuery +{ + public static readonly InlineQuery Default = new(); +} + +internal sealed class NeverObservable : IObservable +{ + public static readonly NeverObservable Instance = new(); + + public IDisposable Subscribe(IObserver observer) => NoopDisposable.Instance; + + private sealed class NoopDisposable : IDisposable + { + public static readonly NoopDisposable Instance = new(); + public void Dispose() { } + } +} + +/// +/// The third-party service-backed authoring overload — uses only the public seam, via the ergonomic +/// ServiceBacked((sp, a) => …) helper (sp is a parameter invoked lazily; gating is automatic). +/// +public static class InlineProviderRulesExtensions +{ + public static ProviderRuleBuilder FromInline( + this ServiceBackedProviderBuilder builder, + Func jsonFactory) + where T : class + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentNullException.ThrowIfNull(jsonFactory); + + return builder.ServiceBacked( + (sp, accessor) => new InlineOptions(() => jsonFactory(sp, accessor)), + _ => InlineQuery.Default); + } +} + +public interface IInlineSource +{ + string GetJson(); +} + +internal sealed class InlineSource : IInlineSource +{ + public string GetJson() => """{ "Value": "from-third-party" }"""; +} + +[Trait("Category", "ServiceBacked")] +[Trait("Type", "Unit")] +public class ThirdPartyServiceBackedProviderTests +{ + [Fact] + public async Task ThirdPartyProvider_AuthorsServiceBackedOverload_ViaPublicSeam() + { + var services = new ServiceCollection(); + services.AddSingleton(); + services.AddCocoarConfiguration(c => c + .UseConfiguration(rules => [ rules.For().FromStaticJson("""{ "Value": "base" }""") ]) + .UseServiceBackedConfiguration(rules => + [ + // The third-party (sp, a) => … overload resolves a DI service to load config. + rules.For().FromInline((sp, _) => sp.GetRequiredService().GetJson()), + ]) + .UseDebounce(25)); + + await using var sp = services.BuildServiceProvider(); + var mgr = sp.GetRequiredService(); + + // Dormant before activation (the third-party overload gated itself via the public WithActivationGate). + Assert.Equal("base", mgr.GetConfig()!.Value); + + await sp.ActivateServiceBackedConfigurationAsync(); + + // Activated: the third-party provider ran and resolved IInlineSource from the container. + Assert.Equal("from-third-party", mgr.GetConfig()!.Value); + } + + [Fact] + public void Context_ServiceProvider_ReadInAuthoringBody_ThrowsWithGuidance() + { + var services = new ServiceCollection(); + + // Reading Context.ServiceProvider eagerly (in the authoring body, before the container exists) is a + // mistake — it must fail loud with guidance, not silently yield null. + var ex = Assert.Throws(() => + services.AddCocoarConfiguration(c => c.UseServiceBackedConfiguration(rules => + { + var builder = rules.For(); + _ = builder.Context.ServiceProvider; // eager read — must throw + return []; + }))); + + Assert.Contains("recompute time", ex.Message); + } +} diff --git a/website/.vitepress/config.ts b/website/.vitepress/config.ts index d373de3..c8c4f7f 100644 --- a/website/.vitepress/config.ts +++ b/website/.vitepress/config.ts @@ -76,6 +76,7 @@ export default defineConfig({ { text: 'DI Setup', link: '/guide/di/setup' }, { text: 'ASP.NET Core', link: '/guide/di/aspnetcore' }, { text: 'Lifetimes & Registration ', link: '/guide/di/lifetimes' }, + { text: 'Service-Backed Config ', link: '/guide/di/service-backed' }, ], }, { diff --git a/website/guide/di/service-backed.md b/website/guide/di/service-backed.md new file mode 100644 index 0000000..17330a4 --- /dev/null +++ b/website/guide/di/service-backed.md @@ -0,0 +1,165 @@ +# Service-Backed Configuration + +Some configuration sources need a service from your application container *to load* — an `IHttpClientFactory`, a Marten `IDocumentStore`, an EF `IDbContextFactory`. But `AddCocoarConfiguration` runs **before** `BuildServiceProvider()`, so those services don't exist yet. This is a hard boundary in every framework: config that needs the container can't also *bootstrap* the container. + +Cocoar solves it the same way Microsoft splits `IConfiguration` (eager, dumb sources) from `IOptions` (lazy, DI-bound) — with a **two-layer model**, in Cocoar's own ordered-layer idiom (see [ADR-006](https://github.com/cocoar-dev/cocoar.configuration/blob/develop/docs/adr/ADR-006-di-aware-configuration.md)). + +| Layer | Method | When | `IServiceProvider`? | +|---|---|---|---| +| **Layer 1** | `UseConfiguration` | eager, at registration (wires the DI plan + bootstrap config) | **no** — file/env/static/HTTP-without-DI | +| **Layer 2** | `UseServiceBackedConfiguration` | lazy, on host start | **yes** — factories receive the container | + +Layer 1 is unchanged and stays DI-free. Layer 2 is an additive, opt-in extension from `Cocoar.Configuration.DI`; the No-DI core never sees an `IServiceProvider`. + +::: tip When do I need this? +Only when a provider must resolve an application service to load — DB-backed config or HTTP via `IHttpClientFactory`. File/env/static and the plain `FromHttp(url)` provider stay in Layer 1. +::: + +## The two authoring surfaces + +```csharp +services.AddCocoarConfiguration(c => c + // Layer 1 — eager, no IServiceProvider, available before the container is built. + .UseConfiguration(rules => + [ + rules.For().FromFile("appsettings.json"), // bootstrap log level + ]) + // Layer 2 — extension from the DI package; factories receive the IServiceProvider. + .UseServiceBackedConfiguration(rules => + [ + rules.For().FromHttp( + (sp, a) => sp.GetRequiredService().CreateClient("cocoar-config"), + "logging.json", pollInterval: TimeSpan.FromSeconds(30)), + + rules.For().FromStorage( + (sp, a) => new MartenConfigBackend(sp.GetRequiredService(), a.Tenant)) + .TenantScoped(), + ])); +``` + +Layer-2 rules merge **after** Layer-1 rules — they win per key, exactly like any later rule. Each `(sp, a)` factory receives the application `IServiceProvider` and the current `IConfigurationAccessor` (its `Tenant` is set inside a tenant pipeline). + +## HTTP via `IHttpClientFactory` + +`FromHttp((sp, a) => HttpClient, url, …)` (from `Cocoar.Configuration.Http`) sources its client from the container — gaining handler pooling/rotation and `AddHttpClient` policies (Polly). The provider does **not** dispose a factory-supplied client. + +```csharp +services.AddHttpClient("cocoar-config") + .ConfigurePrimaryHttpMessageHandler(() => new SocketsHttpHandler()) + .AddPolicyHandler(retryPolicy); + +services.AddCocoarConfiguration(c => c + .UseConfiguration(rules => [ rules.For().FromFile("appsettings.json") ]) + .UseServiceBackedConfiguration(rules => + [ + rules.For().FromHttp( + (sp, a) => sp.GetRequiredService().CreateClient("cocoar-config"), + "https://config.internal/remote.json", pollInterval: TimeSpan.FromSeconds(30)), + ])); +``` + +The plain `FromHttp(url)` overload (which `new`s its own `HttpClient`) stays available for Layer 1 / no-DI. + +## DB-backed config with `FromStorage` + +`FromStorage((sp, a) => IStorageBackend)` reuses Cocoar's storage pipeline: implement `IStorageBackend` (`ReadAsync`/`WriteAsync` over your store) and source it from DI. Combine with `.TenantScoped()` for **DB-config-per-tenant** — the tenant gate and the service-provider gate compose, so the rule runs only inside a tenant pipeline, after the host has started. + +```csharp +public sealed class MartenConfigBackend(IDocumentStore store, string? tenant) : IStorageBackend +{ + public async Task ReadAsync(string key, CancellationToken ct = default) + { + // Open a SHORT-LIVED unit per read on the recompute thread (never hold a session): + await using var session = store.QuerySession(tenant ?? ""); + var doc = await session.LoadAsync(key, ct); + return doc?.Json is { } json ? Encoding.UTF8.GetBytes(json) : null; + } + + public Task WriteAsync(string key, byte[] data, CancellationToken ct = default) => /* … */; +} +``` + +This **exceeds** Microsoft's EF config provider, which `new`s its own `DbContext`: here you use the app's real, DI-managed, tenant-scoped store. + +## Deriving config from a DI service — `FromService` + +When the config simply **comes from a DI service** (no I/O source — an in-memory registry, a computed default, another service's value), you don't need a custom provider at all. `FromService(s => …)` resolves the service from the container and projects it to the config value: + +```csharp +.UseServiceBackedConfiguration(rules => +[ + rules.For().FromService(s => s.Settings), +]) +``` + +This is Cocoar's equivalent of Microsoft's `services.Configure((opts, dep) => …)` / an `IConfigureOptions` with an injected dependency — and the natural target when migrating those. The service is resolved at recompute time (after host start); the rule is dormant until then, like any Layer-2 rule, and composes with `.TenantScoped()`. + +::: warning Synchronous / in-memory only +`FromService` snapshots once per recompute (no change detection) and the projection is synchronous. For I/O-bound sources (DB, HTTP, Key Vault) use an async provider — `FromStorage`, `FromHttp((sp,a)=>…)`, or a custom provider — rather than blocking inside the projection. +::: + +## Lifecycle & the readiness contract + +Layer 2 activates on **host start**. A `IHostedLifecycleService` publishes the root `IServiceProvider` and triggers a **recompute** (never a rebuild) from the Layer-2 boundary — Layer 1 stays stable, the Layer-2 suffix runs and merges on top. + +- Layer-2 values are **guaranteed after host start**. +- A snapshot read (`GetConfig()`) **before** host start returns the **Layer-1 base**; a type that exists *only* in Layer 2 is unresolved (`TryGetConfig` returns `false`). +- Because activation is a recompute on the same backplane, **every live `IReactiveConfig` view receives the Layer-2 value when it lands — even views obtained before the container was built.** + +```csharp +// Wire a Serilog level switch during bootstrap, BEFORE the host runs: +var live = configManager.GetReactiveConfig(); +live.Subscribe(c => levelSwitch.MinimumLevel = Map(c.Level)); +// fires: now (Layer-1 file level) → on host start (Layer-2 remote level) → on every poll change after +``` + +::: warning Subscribe, don't snapshot +To receive the Layer-2 upgrade you must **subscribe** (`IReactiveConfig`), not read a one-time `GetConfig()` / `.CurrentValue` during container build. +::: + +## Failure semantics + +Layer-2 rules are **optional** by default: if the source is down (DB/HTTP unreachable), the recompute rolls back to the last good state, **Layer-1 values persist**, and health goes degraded. A remote outage never faults host startup or nukes your config. + +## Lifetime discipline + +The holder's `sp` is the **root** provider. Resolve **singletons / factories only** (`IDocumentStore`, `IDbContextFactory`, `IHttpClientFactory`) and open **short-lived units per read** on the recompute thread (`store.QuerySession(…)`, `factory.CreateDbContext()`). Never resolve a scoped service from root — config is computed once per tenant/global, cached and reactive, not per request. + +## Precedence vs. gating + +These are separate. **Precedence** is list position (Layer 2 after Layer 1 → wins per key). **Gating** is per-rule and applies only to rules that actually use `sp`. A non-`sp` rule placed in Layer 2 runs eagerly *and* gains the later precedence — so "a non-DI rule must beat a DI-backed rule" is just: declare it once, in Layer 2, after the DI-backed rule. + +## Activation without a Host + +For apps that build their own `IServiceProvider` without an `IHost`, activate manually with the **root** provider: + +```csharp +var provider = services.BuildServiceProvider(); +await provider.ActivateServiceBackedConfigurationAsync(); // publishes sp + runs the Layer-2 recompute +``` + +It is idempotent with the automatic hosted-service activation and a no-op when no Layer-2 rules were registered. + +## Custom (third-party) service-backed providers + +Whether a provider can be service-backed is **entirely the provider author's choice** — the framework just offers the seam. `UseServiceBackedConfiguration(rules => …)` hands each `rules.For()` a public `ServiceBackedProviderBuilder`. Author your own `(sp, a) =>` overload on it via the `ServiceBacked(...)` helper — `sp` arrives as a **parameter** invoked lazily at recompute time, and the rule is gated for you: + +```csharp +// In your provider package — uses only the public surface (no internals): +public static ProviderRuleBuilder FromMyDb( + this ServiceBackedProviderBuilder builder, + Func backendFactory) where T : class + => builder.ServiceBacked( + (sp, a) => new MyOptions(backendFactory(sp, a)), // sp is a param, resolved lazily — never read too early + _ => MyQuery.Default); +``` + +Two things make a provider service-backed: (1) author this `(sp, a)` overload on `ServiceBackedProviderBuilder`, and (2) have the provider's **options carry** the resolved artifact (HTTP carries a `ClientFactory`; LocalStorage an `IStorageBackend`). The provider class itself (`ConfigurationProvider<,>`) stays DI-free — and a service-backed provider is usually its **own** small provider, not a no-DI one retrofitted with fallbacks. See [Building Custom Providers → Service-Backed Providers](/guide/providers/custom#service-backed-providers-di-aware) for a full worked example. + +Because these overloads target `ServiceBackedProviderBuilder`, using them inside the Layer-1 `UseConfiguration` (a plain `TypedProviderBuilder`) is a **compile error** — the type system, not a runtime check, keeps DI-backed loading out of Layer 1. + +## See also + +- [Multi-Tenancy](/guide/multi-tenancy/overview) — `.TenantScoped()` and consuming a tenant's config (`ITenantReactiveConfig`) +- [ASP.NET Core](/guide/di/aspnetcore) +- [ADR-006](https://github.com/cocoar-dev/cocoar.configuration/blob/develop/docs/adr/ADR-006-di-aware-configuration.md) — the design rationale diff --git a/website/guide/multi-tenancy/overview.md b/website/guide/multi-tenancy/overview.md index 7762c52..7f18081 100644 --- a/website/guide/multi-tenancy/overview.md +++ b/website/guide/multi-tenancy/overview.md @@ -57,6 +57,31 @@ var store = manager.GetLocalStorageForTenant("acme"); // per-te A type whose **every** rule is `.TenantScoped()` has no global value. Injecting it into a long-lived (singleton) consumer would be a captive-dependency bug — it would freeze one tenant forever, since the container cannot know the runtime tenant. The DI planner therefore **excludes** purely tenant-scoped types from the global plan. A type that *also* has a global base rule stays injectable (its base value is a valid global config). Consuming services inject the `ConfigManager` / `ITenantConfigurationAccessor` and call `…ForTenant(currentTenant)`. +### Scoped per-request injection (ASP.NET Core) + +So scoped/transient services don't have to thread the tenant id by hand, `Cocoar.Configuration.AspNetCore` offers a **scoped** `ITenantReactiveConfig` that resolves the *current request's* tenant for you. You supply a scoped `ITenantContext` (only your app knows where the tenant lives — a claim, header, or route value); the adapter delegates to `GetReactiveConfigForTenant(tenant)`. + +```csharp +// Register the adapter + a default ITenantContext that reads the tenant from the request: +builder.Services.AddCocoarTenantReactiveConfig(http => http.Request.RouteValues["tenant"]?.ToString()); + +// Ensure the tenant pipeline is warm before it is consumed (e.g. request-start middleware): +app.Use(async (ctx, next) => +{ + if (ctx.Request.RouteValues["tenant"] is string t) + await app.Services.GetRequiredService().EnsureTenantInitializedAsync(t); + await next(); +}); + +// In any scoped/transient service — no tenant id threaded by hand: +public sealed class SmtpSender(ITenantReactiveConfig smtp) +{ + public void Send() => Connect(smtp.CurrentValue.Host); // this request's tenant +} +``` + +The singleton `IReactiveConfig` is **untouched** — it stays the global view, so singletons keep working. A singleton that needs a specific tenant still calls `GetReactiveConfigForTenant(id)` explicitly (it has no ambient request tenant). + ## Feature flags & entitlements per tenant The same source-generated flag/entitlement class is constructed with the **tenant's** `IReactiveConfig`, so it evaluates against that tenant's effective config — **no source-generator change**: @@ -89,6 +114,10 @@ await manager.GetLocalStorageForTenant("acme").SetAsync(x => x.Por A write triggers only that tenant's recompute; other tenants are untouched. Provenance (`DescribeAsync`) is computed over the tenant's own layers. +### DB-backed config per tenant + +When the per-tenant source is a database (Marten / EF) reached through a DI-managed store, use `FromStorage((sp, a) => …).TenantScoped()` — the tenant gate and the service-provider gate compose, so the rule runs only inside a tenant pipeline, after the host has started. See [Service-Backed Configuration](/guide/di/service-backed#db-backed-config-with-fromstorage). + ## Per-tenant secrets Per-tenant secrets reuse the existing **multi-kid certificate folder** — `kid = tenant`. Lay certificates out as `certsRoot/{tenant}/cert.pfx` and each tenant's overlay carries an envelope tagged with its own kid: diff --git a/website/guide/providers/custom.md b/website/guide/providers/custom.md index 2355c2a..3369ab4 100644 --- a/website/guide/providers/custom.md +++ b/website/guide/providers/custom.md @@ -125,6 +125,75 @@ rule.For() .Named("Database Config") ``` +## Service-Backed Providers (DI-aware) + +The `FromDatabase` above takes a **connection string** and `new`s its own `SqlConnection` — so it works at registration time, before the container exists. But what if your provider should use a **DI-managed** resource — an `IDbContextFactory`, a Marten `IDocumentStore`, an `IHttpClientFactory`? Those don't exist when `AddCocoarConfiguration` runs. That's the [two-layer / service-backed model](/guide/di/service-backed): a custom provider opts into it, and **whether it does is entirely your choice as the author**. + +A service-backed provider is **its own provider** — a separate class from the no-DI one above — and carries only what the DI path needs, often just the resolved service. + +The framework builds your provider as `new YourProvider(options)`, so what you resolve has to travel on the **options** (the provider's only input). The natural shape: resolve the DI-managed **factory or store** — a singleton like `IDbConnectionFactory`, `IHttpClientFactory`, or a Marten `IDocumentStore` — and pass it on the options as a plain value; the provider opens a short-lived unit per read from it. + +```csharp +// Options carry the resolved DI singleton (a connection factory). +public sealed record DbConfigOptions(IDbConnectionFactory Connections) : IProviderConfiguration +{ + public string? GenerateProviderKey() => null; // carries a DI-resolved dependency → never share this provider +} + +public sealed record DbConfigQuery(string Key) : IProviderQuery; + +public sealed class DbConfigProvider(DbConfigOptions options) + : ConfigurationProvider(options) +{ + public override async Task FetchConfigurationBytesAsync( + DbConfigQuery query, CancellationToken ct = default) + { + await using var conn = ProviderOptions.Connections.Create(); // short-lived unit, opened per read + await conn.OpenAsync(ct); + + var json = await conn.QuerySingleOrDefaultAsync( + "SELECT JsonValue FROM Configuration WHERE ConfigKey = @Key", new { Key = query.Key }); + + return json is not null ? Encoding.UTF8.GetBytes(json) : "{}"u8.ToArray(); + } + + public override IObservable ChangesAsBytes(DbConfigQuery query) => ObservableHelpers.Never(); +} +``` + +The fluent overload uses the `ServiceBacked(...)` helper. The `(sp, _) => …` you pass is a **factory, not an eager call** — `ServiceBacked` invokes it later, at recompute time, so nothing is resolved when you author the rule. You're describing *how* to build the options, not building them now: + +```csharp +public static ProviderRuleBuilder + FromDatabase(this ServiceBackedProviderBuilder builder, string configKey) where T : class + => builder.ServiceBacked( + (sp, _) => new DbConfigOptions(sp.GetRequiredService()), // runs at recompute, not here + _ => new DbConfigQuery(configKey)); +``` + +::: warning Nothing is resolved in the method body +`FromDatabase` returns immediately at registration — it just hands `ServiceBacked` the `(sp, _) => …` factory. `sp.GetRequiredService<…>()` runs only when the framework calls that factory during a recompute, after the host has started. (For per-read freshness, resolve a **factory/store** and call it inside the provider — `Connections.Create()` above — rather than resolving a live connection here.) +::: + +That's the whole pattern: a Layer-1 provider built against `TypedRuleBuilder`, and a Layer-2 provider built against `ServiceBackedProviderBuilder` — two small providers, one per layer. (If their inputs happen to overlap you can put both overloads on one class, but you rarely need to.) + +Now the provider can pull a DI-managed resource — only inside `UseServiceBackedConfiguration`: + +```csharp +services.AddCocoarConfiguration(c => c + .UseConfiguration(rules => [ /* eager, no-DI bootstrap */ ]) + .UseServiceBackedConfiguration(rules => + [ + rules.For().FromDatabase("AppSettings"), + ])); +``` + +::: tip Type-safe, not stringly-gated +The Layer-2 overload targets `ServiceBackedProviderBuilder`. Calling it inside the Layer-1 `UseConfiguration` (a plain `TypedRuleBuilder`) is a **compile error** — the type system keeps DI-backed loading out of the eager layer. The whole seam (`ServiceBackedProviderBuilder.Context`, `ServiceBackedRuleContext`, `WithActivationGate`) is **public**, so this needs no internals. +::: + +Lifetime discipline (ADR-006 §9): the `IServiceProvider` is the **root** — resolve singletons / factories (`IDbContextFactory`, `IDocumentStore`, `IHttpClientFactory`) and open a short-lived unit per read (as the `await using var conn` above does). Never resolve a scoped service from root. Combine with `.TenantScoped()` for DB-config-per-tenant. See [Service-Backed Configuration](/guide/di/service-backed) for the full lifecycle, readiness, and failure contracts. + ## Change Detection For reactive providers, return an `IObservable` that emits when the source changes: From 618b4b6ddbdfea5450a883d078d534bbd4a4c9cd Mon Sep 17 00:00:00 2001 From: Bernhard Windisch Date: Sat, 30 May 2026 21:18:54 +0200 Subject: [PATCH 04/18] =?UTF-8?q?chore:=20pre-merge=20cleanup=20=E2=80=94?= =?UTF-8?q?=20dead=20code,=20ADR=20accuracy,=20docs,=20public=20seams?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Code: - Remove orphaned internal MasterBackplane Backplane properties (ConfigManager, TenantPipeline) - Tidy a stream-of-consciousness comment in HybridProtectorRegistrar - Add public ProviderObservable / ProviderDisposable helpers (Providers.Abstractions) so custom-provider change streams compile for external authors (+ tests) - Add config-aware FromFile(a => string) overload for per-tenant file paths ADRs: - ADR-005/006: fix stale status + phantom API names (GetFeatureFlagsForTenant, .TenantScoped(), RemoveTenantAsync) and correct the public service-backed seam note - ADR-002/003: replace phantom/stale APIs with the real surface (reference-equality MasterBackplane; FromHttp; real Health API) and repair ADR-003's corrupted prose Docs: - Announce Multi-Tenancy, Service-Backed and secrets-key-publishing in CHANGELOG / website changelog / README; refresh CLAUDE.md (providers, ADR list) - New guide page for the /.well-known/cocoar/encryption-keys endpoints - Fix doc bugs (localstorage casts/link, HttpProviderOptions/ServiceProviderHolder docs) Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 17 ++ CLAUDE.md | 15 +- README.md | 2 + docs/adr/ADR-002-atomic-reactive-updates.md | 257 +++++++++--------- ...-003-provider-consistency-empty-objects.md | 97 +++---- .../adr/ADR-005-multi-tenant-configuration.md | 14 +- docs/adr/ADR-006-di-aware-configuration.md | 4 +- .../ServiceBacked/ServiceProviderHolder.cs | 2 +- .../HttpProviderOptions.cs | 6 +- .../Core/ConfigManager.cs | 2 - .../Core/TenantPipeline.cs | 1 - .../Abstractions/ProviderReactive.cs | 37 +++ .../FileSourceRulesExtensions.cs | 13 + .../Hybrid/HybridProtectorRegistrar.cs | 3 +- .../ProviderReactiveTests.cs | 93 +++++++ website/.vitepress/config.ts | 1 + website/changelog.md | 22 ++ website/guide/providers/custom.md | 12 +- website/guide/providers/localstorage.md | 4 +- website/guide/secrets/key-publishing.md | 73 +++++ 20 files changed, 454 insertions(+), 221 deletions(-) create mode 100644 src/Cocoar.Configuration/Providers/Abstractions/ProviderReactive.cs create mode 100644 src/tests/Cocoar.Configuration.Providers.Tests/ProviderReactiveTests.cs create mode 100644 website/guide/secrets/key-publishing.md diff --git a/CHANGELOG.md b/CHANGELOG.md index da5339e..44f816b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,23 @@ - `ILocalStorage` / `ILocalStorageOverlay` are DI-injectable (single shared singleton) — write your own endpoints with your own validation/normalization/logging - LocalStorageOverride example project - `IProviderServiceRegistration` now supports resolve-time factory registrations (`ProviderServiceRegistration.Singleton(type, factory)`) in addition to eager instances +- **Multi-Tenancy** — the same configuration type resolves to different values per tenant, layered on a shared global base (ADR-005) + - `ITenantConfigurationAccessor` lifecycle on `ConfigManager`: `InitializeTenantAsync` / `EnsureTenantInitializedAsync` / `IsTenantInitialized` / `RemoveTenantAsync` + - `.TenantScoped()` rule marker + `Tenant` on `IConfigurationAccessor` (default-interface member, non-breaking) — author one flat rule list, no second surface + - Per-tenant access: `GetConfigForTenant` / `GetReactiveConfigForTenant` / `GetFeatureFlagsForTenant` / `GetEntitlementsForTenant` / `GetLocalStorageForTenant` + - Tenant-only types are excluded from the global DI plan (avoids the captive-dependency bug); per-tenant flags/entitlements need no source-generator change + - ASP.NET Core: scoped `ITenantReactiveConfig` + `ITenantContext`, and `MapTenantFeatureFlagEndpoints()` / `MapTenantEntitlementEndpoints()` +- **Service-Backed (DI-aware) configuration** — a two-layer model so config providers can use DI-managed services (ADR-006) + - `UseServiceBackedConfiguration(...)` (DI package) — Layer-2 rules whose provider factories receive the application `IServiceProvider` + - `FromStorage((sp, a) => IStorageBackend)`, `FromHttp((sp, a) => HttpClient)`, and `FromService(s => config)` overloads + - providers can use `IHttpClientFactory` / Marten / EF without giving up the no-DI core; activated on host start via `IHostedLifecycleService` (a recompute, never a rebuild) + - public `ServiceBackedProviderBuilder` seam so third-party provider packages can author their own `(sp, a)` overloads + - ServiceBackedConfig example project +- **Secrets encryption-key publishing** — publish the public half of the configured secrets encryption key so a browser/CLI producer can build `cocoar.secret` envelopes + - `ISecretEncryptionKeyProvider` (DI) and ASP.NET Core `MapSecretEncryptionKeyEndpoints()` under `/.well-known/cocoar/encryption-keys` + - `SecretEnvelope` for typed secret-overlay writes; LocalStorage `SetSecretAsync` / `SetSecretEnvelopeAsync` accept pre-encrypted envelopes +- Public `ProviderObservable` / `ProviderDisposable` helpers (in `Cocoar.Configuration.Providers.Abstractions`) for authoring a custom provider's change stream without referencing System.Reactive +- `FromFile(a => …)` config-aware file-path overload (resolves the path from the accessor per recompute) — the natural shape for per-tenant file rules ### Changed diff --git a/CLAUDE.md b/CLAUDE.md index ce602d9..b10fb28 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -36,9 +36,11 @@ dotnet pack ./src -c Release - **ConfigManager** (`src/Cocoar.Configuration/Core/`) - Central orchestrator that manages configuration lifecycle, rule execution, and reactive updates. Always created via `ConfigManager.Create()` or `ConfigManager.CreateAsync()` — constructors are internal. Both take an `Action` lambda and return a fully initialized `ConfigManager`. - **ConfigManagerBuilder** (`src/Cocoar.Configuration/Core/`) - Fluent builder received as parameter in `Create`/`CreateAsync` lambdas. Satellite libraries extend it via extension methods (e.g. `.UseSecretsSetup()`, `.UseFeatureFlags()`, `.UseEntitlements()`). - **Feature Flags & Entitlements** (`src/Cocoar.Configuration/Flags/`) - Source-generated pattern: `partial class` implements `IFeatureFlags` or `IEntitlements`, generator produces constructor and `Config` property (reads `IReactiveConfig.CurrentValue`). Multi-config via tuples (`IFeatureFlags<(T1, T2)>`). These interfaces are the only supported way to define flags and entitlements. -- **Providers** (`src/Cocoar.Configuration/Providers/`) - Abstract configuration sources (File, Environment, CommandLine, HTTP, Static, Observable) +- **Providers** (`src/Cocoar.Configuration/Providers/`) - Abstract configuration sources (File, Environment, CommandLine, HTTP, Static, Observable, LocalStorage) - **Fluent Builders** (`src/Cocoar.Configuration/Fluent/`) - `RulesBuilder` for defining configuration rules with `.For().FromFile()` pattern. `TypedRuleBuilder` has a `where T : class` constraint — configuration types must be reference types. - **SetupBuilder** (`src/Cocoar.Configuration/Configure/`) - DI registration with `.ConcreteType()` and `.Interface()` patterns +- **Multi-Tenancy** (`src/Cocoar.Configuration/Core/TenantPipeline.cs`) - One `ConfigManager` owns a global `TenantPipeline` plus a per-tenant registry on a shared global base. Author one flat rule list with `.TenantScoped()`; `Tenant` on `IConfigurationAccessor`; consume via `…ForTenant(id)` on `ITenantConfigurationAccessor` (explicit, never DI-injected). See ADR-005. +- **Service-Backed (DI-aware) Configuration** (`src/Cocoar.Configuration.DI/ServiceBacked/`) - Two-layer model: eager no-DI `UseConfiguration` (Layer 1) + lazy `UseServiceBackedConfiguration` (Layer 2) whose provider factories receive `IServiceProvider` (so providers can use `IHttpClientFactory`/Marten/EF). Activated on host start via `IHostedLifecycleService` (a recompute, never a rebuild). No-DI core preserved. See ADR-006. ### Recompute Pipeline @@ -83,9 +85,9 @@ SetupDefinition.GetComposer(builder).Add(new ServiceLifetimeCapability(...)); | Project | Purpose | |---------|---------| | `Cocoar.Configuration.Abstractions` | Lightweight interfaces (`IConfigurationAccessor`, `IReactiveConfig`, `ISecret`, `SecretLease`) | -| `Cocoar.Configuration` | Main library: providers, builders, reactive engine, secrets (`Secret`, X.509 encryption), feature flags, entitlements | -| `Cocoar.Configuration.DI` | `AddCocoarConfiguration()` for Microsoft.Extensions.DI (no ASP.NET Core dependency) | -| `Cocoar.Configuration.AspNetCore` | ASP.NET Core integration, health endpoints, feature flag/entitlement REST endpoints | +| `Cocoar.Configuration` | Main library: providers (incl. LocalStorage writable overlay), builders, reactive engine, multi-tenancy, secrets (`Secret`, X.509 encryption), feature flags, entitlements | +| `Cocoar.Configuration.DI` | `AddCocoarConfiguration()` for Microsoft.Extensions.DI (no ASP.NET Core dependency); service-backed (Layer-2) configuration via `UseServiceBackedConfiguration` | +| `Cocoar.Configuration.AspNetCore` | ASP.NET Core integration, health endpoints, feature flag/entitlement REST endpoints (incl. per-tenant), secrets encryption-key endpoints, scoped tenant config adapter | | `Cocoar.Configuration.Http` | Remote config provider (polling, SSE, one-time fetch) | | `Cocoar.Configuration.MicrosoftAdapter` | Bridge to existing `IConfiguration` sources | | `Cocoar.Configuration.Analyzers` | Roslyn analyzers (COCFG001, 002, 003, 005, 006) and source generator (COCFLAG001-003). COCFG004 was removed — enforced by `where T : class` constraint instead. | @@ -117,11 +119,14 @@ Read these ADRs to understand important design choices: - **ADR-001** (`docs/adr/`) - Capabilities system for cross-assembly extensibility - **ADR-002** (`docs/adr/`) - Atomic reactive configuration updates (tuple semantics) - **ADR-003** (`docs/adr/`) - Provider consistency (empty objects on failure) +- **ADR-004** (`docs/adr/`) - Aggregate rules with isolated execution boundary +- **ADR-005** (`docs/adr/`) - Multi-tenant configuration (per-tenant pipelines on a shared global base) +- **ADR-006** (`docs/adr/`) - DI-aware (service-backed) two-layer configuration ## Documentation - `website/` - VitePress documentation site (single source of truth for user-facing docs) -- `docs/adr/` - Architecture Decision Records (ADR-001 through ADR-003) +- `docs/adr/` - Architecture Decision Records (ADR-001 through ADR-006) - `src/Examples/` - Runnable example projects demonstrating individual features ## Local Working Files diff --git a/README.md b/README.md index f055cdb..7e97949 100644 --- a/README.md +++ b/README.md @@ -49,6 +49,8 @@ app.Run(); - **Explicit Layering** — Rules execute in order, last write wins. File, environment, CLI, HTTP. - **Memory-Safe Secrets** — `Secret` with automatic zeroization and X.509 hybrid encryption. - **Feature Flags & Entitlements** — Strongly-typed, source-generated, with expiry health monitoring. +- **Multi-Tenant** — The same config type resolves per tenant on a shared global base; `.TenantScoped()` rules, `…ForTenant(id)` access. +- **DI-Aware Providers** — Opt-in Layer-2 rules whose providers use `IHttpClientFactory` / Marten / EF, without giving up the no-DI core. - **Health Monitoring** — Per-rule status tracking, OpenTelemetry metrics, ASP.NET Core health checks. - **Compile-Time Validation** — Roslyn analyzers catch configuration errors in your IDE. - **First-Class Testing** — `CocoarTestConfiguration` with `AsyncLocal` isolation. diff --git a/docs/adr/ADR-002-atomic-reactive-updates.md b/docs/adr/ADR-002-atomic-reactive-updates.md index 94cd6f3..a061585 100644 --- a/docs/adr/ADR-002-atomic-reactive-updates.md +++ b/docs/adr/ADR-002-atomic-reactive-updates.md @@ -152,29 +152,33 @@ public class MyService } ``` -### 3. Hash-Based Change Detection +### 3. Reference-Equality Change Detection -Only emit when configuration **actually changes**: +Only emit when configuration **actually changes**. Each recompute produces fresh +config instances; the engine compares the new per-type **instance reference** +against the last published reference and suppresses emission when they are +reference-equal: ```csharp // Recompute produces new snapshot oldSnapshot = { AppSettings: v1, DatabaseSettings: v1 } -newSnapshot = { AppSettings: v2, DatabaseSettings: v1 } // Only App changed +newSnapshot = { AppSettings: v2, DatabaseSettings: v1 } // Only App got a new instance -// Per-Type Change Detection: -- Hash(AppSettings v2) != Hash(AppSettings v1) → Changed -- Hash(DatabaseSettings v1) == Hash(DatabaseSettings v1) → Unchanged +// Per-Type Change Detection (DistinctUntilChanged with ReferenceEquals): +- ReferenceEquals(AppSettings v2, AppSettings v1) == false → Changed +- ReferenceEquals(DatabaseSettings v1, DatabaseSettings v1) == true → Unchanged // Emission: -- IReactiveConfig → Emits (value changed) -- IReactiveConfig → No emission (unchanged) +- IReactiveConfig → Emits (reference changed) +- IReactiveConfig → No emission (reference unchanged) - IReactiveConfig<(AppSettings, DatabaseSettings)> → Emits (tuple member changed) ``` **Benefits:** - Avoids spurious emissions on non-changes -- Subscribers only react when values actually differ -- SHA-256 hash over JSON representation +- Subscribers only react when a type gets a new instance +- O(1) reference comparison — no hashing or serialization on the emit path + (`MasterBackplane.CreateTypeProjection` ends in `.DistinctUntilChanged(ReferenceEqualityComparer.Instance)`) --- @@ -182,93 +186,93 @@ newSnapshot = { AppSettings: v2, DatabaseSettings: v1 } // Only App changed ### Core Components -**1. ReactiveConfigManager** (~250 lines) +**1. MasterBackplane** (single source of truth) -Manages reactive subscriptions and change detection: +`MasterBackplane` holds the current `ConfigSnapshot` in a `SimpleBehaviorSubject` +and atomically publishes new snapshots. Per-type reactive consumers subscribe to +a **type projection** built lazily over that snapshot stream. The projection +selects the type out of each snapshot and gates emissions by reference equality: ```csharp -internal sealed class ReactiveConfigManager +internal sealed class MasterBackplane : IDisposable { - // Per-type BehaviorSubjects - private readonly ConcurrentDictionary _subjects = new(); - - // Per-type hash tracking for change detection - private readonly ConcurrentDictionary _lastHashes = new(); - - // Per-pass emissions (for observing recompute events) - private readonly ConcurrentDictionary _perPassSubjects = new(); - - public IReactiveConfig GetReactiveConfig(Func accessor) - { - var subject = GetOrCreateSubject(); - return new ReactiveConfig(subject); - } - - public void NotifyConfigurationObservers(Func accessor) - { - // Called after successful recompute transaction - // Computes hashes and emits only for changed types - foreach (var type in _subjects.Keys) - { - var value = accessor(type); - var newHash = ComputeHash(value); - - if (HasChanged(type, newHash)) - { - UpdateHash(type, newHash); - PublishToSubject(type, value); // Atomic emission - } - } - } + private readonly SimpleBehaviorSubject _snapshotSubject; + private readonly ConcurrentDictionary _typeProjectionCache = new(); + + // Atomic publish: all type projections update from a single snapshot + public void Publish(ConfigSnapshot snapshot) => _snapshotSubject.OnNext(snapshot); + + private IObservable CreateTypeProjection() where T : class => + _snapshotSubject + .Select(snapshot => snapshot.GetConfig() /* + interface mapping */) + .Where(config => config != null) + // Uses ReferenceEquals — no hashing on the emit path + .DistinctUntilChanged(ReferenceEqualityComparer.Instance); } ``` -**2. PassEvent System** (Supporting Transactional Semantics) +**2. ReactiveConfigManager** (wrapper cache over the backplane) + +Holds the `MasterBackplane` plus a single `_reactiveConfigs` dictionary of +per-type wrappers. `GetReactiveConfig` returns a cached, backplane-backed +`BackplaneReactiveConfig` whose `CurrentValue` reads from the backplane and +whose `Subscribe` forwards to the type projection: ```csharp -public readonly struct PassEvent +internal sealed class ReactiveConfigManager : IDisposable { - public long PassId { get; } // Recompute pass identifier - public T Value { get; } // Configuration value - public DateTime TimestampUtc { get; } -} + private readonly ConcurrentDictionary _reactiveConfigs = new(); + private MasterBackplane? _backplane; -// Usage: Observe every recompute attempt (not just changes) -config.ObservePerPass().Subscribe(passEvent => -{ - Console.WriteLine($"Pass {passEvent.PassId} at {passEvent.TimestampUtc}"); - // Useful for monitoring, debugging, auditing -}); -``` + public IReactiveConfig GetReactiveConfig(Func fallbackAccessor) where T : class => + (IReactiveConfig)_reactiveConfigs.GetOrAdd( + typeof(T), _ => new BackplaneReactiveConfig(_backplane!)); + + private sealed class BackplaneReactiveConfig : IReactiveConfig, IDisposable where T : class + { + private readonly MasterBackplane _backplane; + private readonly IObservable _observable; + + public BackplaneReactiveConfig(MasterBackplane backplane) + { + _backplane = backplane; + _observable = backplane.GetTypeProjection(); + } -**Why PassEvent Exists:** + public T CurrentValue => _backplane.GetConfig() ?? throw new InvalidOperationException(...); + public IDisposable Subscribe(IObserver observer) => _observable.Subscribe(observer); + } +} +``` -- **Change-based** (`IReactiveConfig`): Emits only when value changes (primary API) -- **Pass-based** (`ObservePerPass`): Emits on every recompute (monitoring/debugging) -- **Use case**: Track recompute frequency even if values don't change -- **Use case**: Audit all configuration refresh attempts +There are no per-type subjects, hash dictionaries, or per-pass subjects — a +single snapshot subject plus reference-equality projections provide change +detection. **3. Tuple Reactive Factory** -Handles flattening of nested tuples for atomic subscriptions: +Handles flattening of nested tuples for atomic subscriptions. The factory +reflects over the `ValueTuple` fields to discover the element types (recursing +into `Rest` for tuples larger than 7), validates each element is a configured / +exposed type, primes each element's reactive config, then instantiates a +`ReactiveTupleConfig<>` over the same `MasterBackplane`. There is no +`Observable.CombineLatest` — the tuple reads all members from one atomic +snapshot: ```csharp -internal sealed class ReactiveConfigurationFactory +internal class ReactiveConfigurationFactory(/* ... */) { - public IReactiveConfig<(T1, T2, T3)> GetReactiveConfig( - Func accessor1, - Func accessor2, - Func accessor3) + private object CreateTupleReactiveConfig(Type tupleType) { - // Create combined observable that emits when ANY member changes - // but always provides ALL members atomically - var combined = Observable.CombineLatest( - _reactiveManager.GetReactiveConfig(accessor1).Value, - _reactiveManager.GetReactiveConfig(accessor2).Value, - _reactiveManager.GetReactiveConfig(accessor3).Value, - (a, b, c) => (a, b, c)); - - return new ReactiveTupleConfig<(T1, T2, T3)>(combined); + var elementTypes = FlattenTuple(tupleType).ToArray(); // reflection-flatten + + // Validate + prime each distinct element's reactive config + foreach (var et in elementTypes.Distinct()) { /* prime element type */ } + + // One ReactiveTupleConfig over the backplane — all members from one snapshot + var generic = typeof(ReactiveTupleConfig<>).MakeGenericType(tupleType); + return Activator.CreateInstance( + generic, accessor, backplaneAccessor(), reactiveConfigManager, logger, bindingRegistry)!; } } ``` @@ -288,11 +292,12 @@ internal sealed class ReactiveConfigurationFactory └─ Failure → RollbackUpdate() 3. Change Detection (on success only) - ├─ For each registered type: - │ ├─ Compute SHA-256 hash of new value - │ ├─ Compare with last known hash - │ └─ If changed → Mark for emission - └─ Emit marked types atomically + ├─ Publish the new snapshot to the MasterBackplane + ├─ For each registered type projection: + │ ├─ Select the type's new instance from the snapshot + │ ├─ Compare with last published reference (ReferenceEquals) + │ └─ If reference changed → emit + └─ Emit changed types atomically 4. Subscriber Notification ├─ Single-type: Emits if that type changed @@ -309,24 +314,24 @@ internal sealed class ReactiveConfigurationFactory ✅ **Atomic Consistency**: Subscribers **never** see partial updates ✅ **Transactional Safety**: Failed recomputes don't corrupt state ✅ **Type-Safe**: Compile-time checked tuple subscriptions -✅ **Hash-Based Efficiency**: No spurious emissions on non-changes +✅ **Reference-Equality Efficiency**: O(1) change detection, no spurious emissions on non-changes ✅ **Flexible Granularity**: Subscribe to single types or tuples ✅ **Automatic Rollback**: Errors preserve last known good state -✅ **Rx Integration**: Works with standard System.Reactive operators +✅ **Zero External Dependencies**: `IReactiveConfig : IObservable` uses only BCL types — no System.Reactive in shipped packages ✅ **Observable by Design**: Configuration as first-class reactive stream ### Trade-offs -⚠️ **Complexity**: ~250 lines for ReactiveConfigManager (justified by correctness) -⚠️ **Memory**: Dual dictionaries per type (subjects + hashes) - ~100 bytes/type +⚠️ **Complexity**: A backplane plus per-type projections and tuple flattening (justified by correctness) +⚠️ **Memory**: One snapshot subject plus one cached wrapper/projection per type — a single dictionary, not dual ⚠️ **Tuple Limitation**: C# supports tuples up to 8 members (combine with nesting if needed) -⚠️ **Hash Computation**: SHA-256 over JSON per type per recompute (~1-5ms overhead) +⚠️ **Reflection**: Tuple flattening uses reflection (results are cached per type) **Why Complexity Is Acceptable:** - Atomic guarantees are **non-negotiable** for correctness -- Memory overhead is **negligible** (100 bytes × 10 types = 1KB) -- Hash computation is **trivial** compared to provider I/O (file/HTTP) +- Memory overhead is **negligible** (one wrapper/projection per type) +- Reference-equality change detection is **O(1)** — no hashing or serialization on the emit path - Alternative (IOptionsMonitor) has **unfixable race conditions** ### Negative @@ -484,37 +489,16 @@ public class DatabasePool **Behavior:** - Emits **when any member changes** - **All members are from the same snapshot** (atomic) -- If only `AppSettings` changed, still get all three (but only AppSettings hash differs) - -### Example 3: Per-Pass Observation (Monitoring) - -```csharp -public class ConfigAuditor -{ - public ConfigAuditor(IReactiveConfig config) - { - config.ObservePerPass().Subscribe(passEvent => - { - Console.WriteLine( - $"Pass {passEvent.PassId} at {passEvent.TimestampUtc:O} → {passEvent.Value.ApiUrl}"); - }); - } -} -``` +- If only `AppSettings` changed, still get all three (but only `AppSettings` has a new reference) -**Behavior:** -- Emits on **every recompute**, even if value unchanged -- Useful for monitoring recompute frequency -- PassId tracks transaction identity - -### Example 4: Health Monitoring Integration +### Example 3: Health Monitoring Integration ```csharp public class ConfigHealthService { public ConfigHealthService( IReactiveConfig<(AppSettings, DatabaseSettings)> config, - IConfigurationHealthService health) + ConfigManager configManager) { // Monitor config changes config.Subscribe(tuple => @@ -522,11 +506,11 @@ public class ConfigHealthService var (app, db) = tuple; ValidateConsistency(app, db); }); - - // Monitor recompute health - health.HealthStatus.Subscribe(status => + + // Check recompute health after a change + config.Subscribe(_ => { - if (status == ConfigurationHealthStatus.Unhealthy) + if (configManager.HealthStatus == HealthStatus.Unhealthy) { AlertOps("Configuration recompute failed!"); } @@ -535,6 +519,11 @@ public class ConfigHealthService } ``` +`ConfigManager` exposes the current health as `HealthStatus` +(`Unknown`/`Healthy`/`Degraded`/`Unhealthy`) plus the `IsHealthy` convenience +flag. A failed required rule leaves the last good configuration in place and sets +`HealthStatus` to `Unhealthy`. + --- ## Performance Characteristics @@ -546,9 +535,9 @@ public class ConfigHealthService - 3 config types - Total time: ~50-200ms (dominated by HTTP polling) -**Hash Computation:** -- SHA-256 over JSON (~1KB config): ~0.5-2ms per type -- 3 types: ~1.5-6ms total +**Change Detection:** +- Reference comparison per type (`DistinctUntilChanged(ReferenceEquals)`): O(1), effectively free +- No hashing or serialization on the emit path - **Negligible** compared to provider I/O **Emission Overhead:** @@ -557,15 +546,14 @@ public class ConfigHealthService - **Trivial** overhead **Memory per Type:** -- BehaviorSubject: ~40 bytes -- Hash storage: ~32 bytes (SHA-256 hex string) -- PassEvent subject: ~40 bytes (if used) -- **Total: ~100 bytes per type** +- One cached `BackplaneReactiveConfig` wrapper + its type projection +- No per-type hash storage, no per-pass subject +- A single shared snapshot subject backs all types **Typical app (10 config types, 20 subscribers):** -- Memory: ~1KB for subjects + ~640 bytes for hashes = **~2KB total** +- Memory: one snapshot subject + ~10 cached wrappers/projections = negligible - Recompute time: ~50-200ms (provider I/O) -- Hash time: ~5-20ms (computation) +- Change detection: O(1) reference compares (effectively free) - Emission time: ~1-10ms (notification) **Conclusion:** Performance overhead is **negligible** compared to correctness benefits. @@ -576,9 +564,8 @@ public class ConfigHealthService **Unit Tests:** - Atomic emission on multi-config change -- No emission when hashes unchanged +- No emission when a type's instance reference is unchanged - Rollback on required rule failure -- PassEvent emissions independent of change-based **Integration Tests:** - File change triggers atomic tuple update @@ -587,7 +574,7 @@ public class ConfigHealthService **Property Tests:** - No subscriber ever sees partial state -- Hash changes if and only if value changes +- A type emits if and only if it gets a new instance reference - Transaction never commits partial updates --- @@ -644,7 +631,7 @@ Observable.CombineLatest( ```csharp config.Subscribe(tuple => { - var (app, db) = tuple; // Built-in hash-based change detection + var (app, db) = tuple; // Built-in reference-equality change detection RebuildState(app, db); }); ``` @@ -653,6 +640,8 @@ config.Subscribe(tuple => ## Future Enhancements +The following are aspirational sketches — **none are implemented yet**. + **1. Snapshot Diffing API** ```csharp @@ -683,11 +672,11 @@ config.Sample(TimeSpan.FromSeconds(1)) // At most once per second ## References ### Internal -- `Reactive/ReactiveConfigManager.cs` - Core implementation -- `Reactive/ReactiveConfigurationFactory.cs` - Tuple flattening -- `Reactive/ReactiveConfig.cs` - Single-type wrapper +- `Core/MasterBackplane.cs` - Snapshot subject + per-type reference-equality projections +- `Reactive/ReactiveConfigManager.cs` - Backplane-backed wrapper cache (`BackplaneReactiveConfig`) +- `Reactive/ReactiveConfigurationFactory.cs` - Tuple flattening (reflection) over the backplane - `Reactive/ReactiveTupleConfig.cs` - Tuple wrapper -- `Core/ConfigurationEngine.cs` - Recompute transaction +- `Core/ConfigurationEngine.cs` - Recompute transaction (BeginUpdate/CommitUpdate/RollbackUpdate) ### External - [System.Reactive Documentation](https://github.com/dotnet/reactive) diff --git a/docs/adr/ADR-003-provider-consistency-empty-objects.md b/docs/adr/ADR-003-provider-consistency-empty-objects.md index 08166a8..3dedc7d 100644 --- a/docs/adr/ADR-003-provider-consistency-empty-objects.md +++ b/docs/adr/ADR-003-provider-consistency-empty-objects.md @@ -33,8 +33,8 @@ rule.For().FromCommandLine("--app:") rule.For().FromFile("config.json") // File doesn't exist → Throws FileNotFoundException → null (unavailable) -// HttpPollingProvider -rule.For().FromHttpPolling("http://api/config") +// HttpProvider +rule.For().FromHttp("http://api/config") // Endpoint down → Throws → null (unavailable) ``` @@ -97,8 +97,8 @@ The system has **asymmetric failure handling**: - App continues with last known good configuration for all types **Optional Rules** (`Required: false`, default): -- Provider throws → `HandleFailure()` returns `SkipResult()` with `include: false` -- `ProcessRuleResult()` sets `LastJsonContribution = null` for that rule +- Provider throws → `HandleFailure()` skips the rule's contribution +- `LastJsonContribution` is left `null` for that rule - Recompute **continues** with other rules - If this was the **only rule** for a type → Type not in `mergedConfigs` → **GetConfig returns null** @@ -115,10 +115,10 @@ This behavior is **intentional per documentation**, but creates the problem: **" --- ## Decision -Fix: All providers now return empty JSON objects (`{}`) when they have no data, regardless of reason. Health monitoring tracks source availability separately.** + +**All providers return empty JSON objects (`{}`) when they have no data, regardless of reason. Health monitoring tracks source availability separately.** This fixes the inconsistency bug and aligns all providers with the correct "graceful degradation" behavior that was already working for collection-based providers. -**All providers will return empty JSON objects (`{}`) when they have no data, regardless of reason. Health monitoring will track source availability separately.** ### Core Principle @@ -149,7 +149,7 @@ This fixes the inconsistency bug and aligns all providers with the correct "grac // All providers work the same way rule.For().FromFile("config.json") // Always returns object rule.For().FromEnvironment("APP_") // Always returns object -rule.For().FromHttpPolling("http://api") // Always returns object +rule.For().FromHttp("http://api") // Always returns object ``` **2. Predictability** @@ -186,18 +186,14 @@ rule.For().FromFile("config.json") // ✅ Clear intent **5. Better Observability** ```csharp -// Health monitoring shows the real issue -{ - "status": "Degraded", - "rules": [ - { - "type": "Config", - "status": "Down", - "error": "File not found: config.json", - "timestamp": "2025-01-11T10:30:00Z" - } - ] -} +// Data still flows (Config has C# defaults), but health reflects the real issue. +// Overall status is derived from per-rule outcomes by the health tracker: +manager.HealthStatus; // HealthStatus.Degraded — an optional rule failed +manager.IsHealthy; // false + +// Per-rule detail is tracked on the rule manager: +// LastOutcome → RuleExecutionOutcome.Failed +// LastFailureException → FileNotFoundException("config.json") ``` **6. Graceful Degradation** @@ -212,8 +208,8 @@ rule.For().FromFile("config.json") // ✅ Clear intent **Change in RuleManager:** ```csharp -// Current: -private (bool include, ReadOnlyMemory bytes) HandleFailure(Exception ex) +// Before: +private ReadOnlyMemory HandleFailure(Exception ex) { LastOutcome = RuleExecutionOutcome.Failed; LastFailureException = ex; @@ -224,11 +220,11 @@ private (bool include, ReadOnlyMemory bytes) HandleFailure(Exception ex) } _logger.OptionalRuleFailed(ex, ...); - return SkipResult(); // ❌ Returns include: false + // ❌ Skipped the rule's contribution → type may be absent → null } -// New: -private (bool include, ReadOnlyMemory bytes) HandleFailure(Exception ex) +// After: +private ReadOnlyMemory HandleFailure(Exception ex) { LastOutcome = RuleExecutionOutcome.Failed; LastFailureException = ex; // ✅ Still tracked for health @@ -239,7 +235,7 @@ private (bool include, ReadOnlyMemory bytes) HandleFailure(Exception ex) } _logger.OptionalRuleFailed(ex, ...); - return EmptyObjectResult(); // ✅ Returns include: true, bytes: "{}" + return EmptyObjectResult(); // ✅ Contributes "{}"u8 → object with C# defaults } ``` @@ -271,7 +267,9 @@ It becomes a **static configuration safety check** rather than a runtime availab ## Consequences -### Bug fixed** - All providers now behave identically (as intended) +### Positive + +✅ **Bug fixed** - All providers now behave identically (as intended) ✅ **Predictability** - Types are always available if configured ✅ **No workarounds needed** - Eliminates environment var hacks ✅ **No null checks** - Simpler consumer code @@ -284,50 +282,37 @@ It becomes a **static configuration safety check** rather than a runtime availab ⚠️ **Behavioral change** - Code checking for null to detect optional rule failures will no longer see null ⚠️ **Documentation update** - PART2 article needs revision to reflect correct behavior -### Not a Breaking (If Needed) - -Users who were working around the bug by checking for null to detect failures: +### Not a Breaking Change (In Practice) -**Before (working around the buga breaking change because: +Users who were working around the bug by checking for null to detect failures may need to adjust, but this is not a breaking change because: - The documented intent was "graceful degradation" for optional rules - Collection providers already demonstrated the correct behavior (returning `{}`) - The null return was inconsistent and required hacky workarounds - All 349 tests passed without modification after the fix -- No legitimate use case for "optional rule returns null" that isn't better served by health monitoringects -⚠️ **Documentation update** - PART2 article needs revision -⚠️ **Migration required** - Users relying on null checks need adjustment +- No legitimate use case for "optional rule returns null" that isn't better served by health monitoring + +### Migration (If Needed) + +Replace null checks (which were a workaround for the bug) with the proper health API: -### Migrausing the proper API):** ```csharp var config = manager.GetConfig(); UseConfig(config); // Always works, may have defaults -// Proper way to check if source is healthy: -var health = manager.GetHealthService().GetCurrentSnapshot(); -var rule = health.Rules.FirstOrDefault(r => r.ConfigType == "OptionalConfig"); -if (rule?.Status == RuleResultStatus.Down) +// Proper way to check if the configuration is healthy: +if (!manager.IsHealthy) { - _logger.LogWarning("Config source unavailable: {Error}", rule.ErrorMessage); + // manager.HealthStatus is Degraded when an optional rule failed. + // Per-rule detail (LastOutcome, LastFailureException) is exposed through + // the rule managers for diagnostics and ConfigHub observability. + _logger.LogWarning("Configuration is degraded: {Status}", manager.HealthStatus); } ``` **Note:** Most code won't need changes - checking for null was a workaround for the bug, and most users either: 1. Used DI injection (never saw null) -2. Used the config directly (relied on defaults) to reflect the fixed behavior: - -**Before (testing buggy behavior):** -3. Had workarounds like adding `FromEnvironment("FAKE_")` rules (no longer needed)csharp -var config = manager.GetConfig(); -UseConfig(config); // Always works, may have defaults - -// Optional: Check if source is healthy -var health = manager.GetHealthService().GetCurrentSnapshot(); -var rule = health.Rules.FirstOrDefault(r => r.ConfigType == "OptionalConfig"); -if (rule?.Status == RuleResultStatus.Down) -{ - _logger.LogWarning("Config source unavailable: {Error}", rule.ErrorMessage); -} -``` +2. Used the config directly (relied on defaults) +3. Had workarounds like adding `FromEnvironment("FAKE_")` rules (no longer needed) ### Testing Impact @@ -344,8 +329,8 @@ Assert.NotNull(result); // Returns empty object Assert.Equal(default, result.SomeProperty); // C# defaults present // Check health instead: -var health = manager.GetHealthService().GetCurrentSnapshot(); -Assert.Equal(ConfigurationHealthStatus.Degraded, health.Status); +Assert.Equal(HealthStatus.Degraded, manager.HealthStatus); +Assert.False(manager.IsHealthy); ``` --- diff --git a/docs/adr/ADR-005-multi-tenant-configuration.md b/docs/adr/ADR-005-multi-tenant-configuration.md index 2d08daf..73b5e1f 100644 --- a/docs/adr/ADR-005-multi-tenant-configuration.md +++ b/docs/adr/ADR-005-multi-tenant-configuration.md @@ -1,6 +1,6 @@ # ADR-005: Multi-Tenant Configuration -**Status:** Accepted — implementation in progress on `feature/multitenant` (core config + lifecycle + automatic fan-out done; per-tenant reactive/flags/entitlements/localstorage/secrets, DI exclusion, AspNetCore, docs in progress) +**Status:** Accepted — implemented on `feature/multitenant` **Date:** 2026-05-29 (updated 2026-05-30) **Decision Makers:** Core Team **Type:** Feature / Architecture @@ -95,11 +95,11 @@ Tenant-scoped values are obtained by **passing the tenant id**, never by DI inje var smtp = mgr.GetConfigForTenant(tenantId); // sync var live = mgr.GetReactiveConfigForTenant(tenantId); var store = mgr.GetLocalStorageForTenant(tenantId); // per-tenant write facade -var flags = mgr.GetFlagsForTenant(tenantId); +var flags = mgr.GetFeatureFlagsForTenant(tenantId); var ents = mgr.GetEntitlementsForTenant(tenantId); ``` -**Tenant-scoped types/flags are NOT DI-injectable.** Injecting one into a long-lived (Singleton) consumer would be a captive-dependency bug — it would freeze one tenant forever, since the container cannot know the runtime tenant. The `ServiceRegistrationPlanner` therefore tags and **excludes** `ForEachTenant` types from the normal DI plan. Global types remain injectable as today. A consuming service injects the `ConfigManager` / `ITenantConfigurationAccessor` and calls `GetFlagsForTenant(currentTenant)` — explicitly tenant-aware, which is the correct shape for multi-tenant code. +**Tenant-scoped types/flags are NOT DI-injectable.** Injecting one into a long-lived (Singleton) consumer would be a captive-dependency bug — it would freeze one tenant forever, since the container cannot know the runtime tenant. The `ServiceRegistrationPlanner` therefore tags and **excludes** types whose every rule is `.TenantScoped()` from the normal DI plan. Global types remain injectable as today. A consuming service injects the `ConfigManager` / `ITenantConfigurationAccessor` and calls `GetFeatureFlagsForTenant(currentTenant)` — explicitly tenant-aware, which is the correct shape for multi-tenant code. ### 6. Fan-out — automatic via per-tenant subscriptions (v1) @@ -113,7 +113,7 @@ Each tenant snapshot layers on the global base, so a change to the **global** ba The tenant dimension is unified by the factory + bundle: -- **Feature Flags / Entitlements** become tenant-aware **without a source-generator change**: the generated flag class already reads an injected `IReactiveConfig`; tenant-awareness means constructing it with the **tenant's** `IReactiveConfig`. `GetFlagsForTenant(id)` is a per-`(tenant, TFlags)` factory/cache over the existing generated class. The context-aware evaluator and the REST endpoints (`MapFeatureFlagEndpoints`) gain a tenant dimension (e.g. a route segment). +- **Feature Flags / Entitlements** become tenant-aware **without a source-generator change**: the generated flag class already reads an injected `IReactiveConfig`; tenant-awareness means constructing it with the **tenant's** `IReactiveConfig`. `GetFeatureFlagsForTenant(id)` is a per-`(tenant, TFlags)` factory/cache over the existing generated class. The context-aware evaluator and the REST endpoints (`MapFeatureFlagEndpoints`) gain a tenant dimension (e.g. a route segment). - **LocalStorage** per tenant: reads fall out of the factory (`FromLocalStorage(BackendFor(tenant))`; file backend = a folder per tenant); writes go through a per-tenant `GetLocalStorageForTenant(id)` facade pointing at the tenant's backend. - **Secrets** are already tenant-capable via folder mode (`kid` = tenant subfolder routes decryption); a tenant writes its encrypted envelope to its own backend, decrypted with its own cert. @@ -140,11 +140,11 @@ Tenant methods live on a **new** `ITenantConfigurationAccessor` that `ConfigMana | ~~`TenantFanOutCoordinator`~~ | **Not built in v1** — full-list-per-tenant gives automatic fan-out (§6); coordinator only needed if seed-from-global lands | Deferred | | `Core/ConfigurationEngine.cs` | seed-from-global recompute variant — **deferred** (§3); v1 re-runs the full list per tenant (correct, unoptimized) | Deferred | | `Core/MasterBackplane.cs`, `ConfigurationState.cs`, `ConfigurationAccessor.cs` | Instantiated per tenant (no internal change); per-tenant accessor so the recompute-window fallback reads tenant JSON, not global | Additive | -| `Rules/ConfigRule.cs` (+ Fluent) | `ForEachTenant((r, tenant) => ConfigRule[])` builder surface; precedent is `AggregateConfigRule` | Additive | -| `DI/ServiceRegistrationPlanner.cs` | Tag/exclude `ForEachTenant` types from the normal DI plan | Additive | +| `Rules/ConfigRule.cs` (+ Fluent) | `.TenantScoped()` marker on the rule builder (AND-composed with any `When`); `Tenant` on the accessor | Additive | +| `DI/ServiceRegistrationPlanner.cs` | Tag/exclude types whose every rule is `.TenantScoped()` from the normal DI plan | Additive | | `DI/ServiceDescriptorEmitter.cs` | (Only if/when ambient injection is ever wanted — currently **out of scope**, see §5) | — | | Abstractions | New `ITenantConfigurationAccessor`; existing `IConfigurationAccessor` unchanged | Additive | -| Flags/Entitlements | `GetFlagsForTenant`/`GetEntitlementsForTenant` factory/cache (no generator change); tenant dimension on evaluator + REST endpoints | Additive | +| Flags/Entitlements | `GetFeatureFlagsForTenant`/`GetEntitlementsForTenant` factory/cache (no generator change); tenant dimension on evaluator + REST endpoints | Additive | **Net:** one structural change (ConfigManager ownership) + one new subsystem (fan-out coordinator). Everything else is additive reuse of existing per-instance machinery. No rewrite of the recompute/snapshot/reactive cores. diff --git a/docs/adr/ADR-006-di-aware-configuration.md b/docs/adr/ADR-006-di-aware-configuration.md index e0c34c2..b60a56c 100644 --- a/docs/adr/ADR-006-di-aware-configuration.md +++ b/docs/adr/ADR-006-di-aware-configuration.md @@ -214,8 +214,8 @@ Plus the §11 trap: never re-register `IReactiveConfig` as scoped. - **Gating granularity:** ✅ per-`sp`-usage. Each `sp`-using overload attaches a dedicated `ActivationGate`; a non-`sp` rule placed in Layer 2 runs eagerly and still wins by position. - **Naming:** ✅ `UseServiceBackedConfiguration`; factory overloads `FromStorage((sp,a)=>IStorageBackend)` and `FromHttp((sp,a)=>HttpClient)`. - **Activation hook:** ✅ `IHostedLifecycleService`, acting in `StartingAsync` (before any regular `IHostedService.StartAsync`), so Layer 2 is live before app/hosted-service code reads config. A manual `IServiceProvider.ActivateServiceBackedConfigurationAsync()` covers non-host scenarios; both are idempotent (the holder publishes the provider exactly once). Consumers that read a snapshot *during* container build see the Layer-1 base; the readiness contract (§7) requires a **subscription** to receive the upgrade. -- **Append-rules core seam:** ✅ `ConfigManagerBuilder.AddServiceBackedRules(IEnumerable)` appends after Layer 1 and records `ConfigManager.ServiceBackedLayerStartIndex`. The sp-gate ambient is the internal core `ServiceBackedRuleContext` (BCL `IServiceProvider` only; never on the public surface), read by both the DI and Http overloads. -- **DB change-detection:** still out of scope here — poll (via `FromStorage` on a polling backend) or app-driven `ReloadTenantAsync`/`RemoveTenantAsync`. Push (`LISTEN/NOTIFY`) remains separate, future work. +- **Append-rules core seam:** ✅ `ConfigManagerBuilder.AddServiceBackedRules(IEnumerable)` appends after Layer 1 and records `ConfigManager.ServiceBackedLayerStartIndex`. The sp-gate seam is the **public**, **type-scoped** (not ambient) `ServiceBackedRuleContext` (BCL `IServiceProvider` only — the core never names a DI type): it is carried by the public `ServiceBackedProviderBuilder.Context` and read by the DI, Http, and third-party `(sp,a)` overloads. +- **DB change-detection:** still out of scope here — poll (via `FromStorage` on a polling backend) or app-driven re-init (`RemoveTenantAsync` then `InitializeTenantAsync`; there is no in-place reload). Push (`LISTEN/NOTIFY`) remains separate, future work. --- diff --git a/src/Cocoar.Configuration.DI/ServiceBacked/ServiceProviderHolder.cs b/src/Cocoar.Configuration.DI/ServiceBacked/ServiceProviderHolder.cs index 60580f8..fcba698 100644 --- a/src/Cocoar.Configuration.DI/ServiceBacked/ServiceProviderHolder.cs +++ b/src/Cocoar.Configuration.DI/ServiceBacked/ServiceProviderHolder.cs @@ -3,7 +3,7 @@ namespace Cocoar.Configuration.DI; /// /// Holds the application root for service-backed (Layer-2, ADR-006) /// configuration. Null until the container is built; set once — on host start — by the activation hosted -/// service (or a manual ActivateServiceBackedConfiguration call). The instance is captured in the +/// service (or a manual ActivateServiceBackedConfigurationAsync call). The instance is captured in the /// closures of the sp-gated Layer-2 rules so their factories can resolve services lazily at recompute time, /// and is registered as a DI singleton so the activator receives the very same instance. /// diff --git a/src/Cocoar.Configuration.Http/HttpProviderOptions.cs b/src/Cocoar.Configuration.Http/HttpProviderOptions.cs index 689ab84..91e0aa2 100644 --- a/src/Cocoar.Configuration.Http/HttpProviderOptions.cs +++ b/src/Cocoar.Configuration.Http/HttpProviderOptions.cs @@ -39,9 +39,9 @@ public sealed class HttpProviderOptions : IProviderConfiguration /// /// Optional factory for an externally-owned — used by the service-backed /// (Layer-2, ADR-006) FromHttp((sp,a)=>…) overload to source a client from - /// IHttpClientFactory. Invoked lazily, only when the provider is (re)built, so the - /// is read at recompute time. When set, the provider does NOT dispose the - /// client (the factory owns the pooled handler). Takes precedence over . + /// IHttpClientFactory. The outer (sp, accessor) is captured at build time, but this factory is + /// invoked per fetch and per SSE (re)connection, so the factory's handler rotation applies. When set, the + /// provider does NOT dispose the client (the factory owns the pooled handler). Takes precedence over . /// Not serialized for provider key generation. /// [JsonIgnore] diff --git a/src/Cocoar.Configuration/Core/ConfigManager.cs b/src/Cocoar.Configuration/Core/ConfigManager.cs index 4e62fcf..7423781 100644 --- a/src/Cocoar.Configuration/Core/ConfigManager.cs +++ b/src/Cocoar.Configuration/Core/ConfigManager.cs @@ -164,8 +164,6 @@ internal ConfigManager(IEnumerable rules, Func internal object? ServiceBackedHolder { get; set; } - internal MasterBackplane Backplane => _state.Backplane; - internal ConfigManager Initialize() { if (_initialized != 0) diff --git a/src/Cocoar.Configuration/Core/TenantPipeline.cs b/src/Cocoar.Configuration/Core/TenantPipeline.cs index bd95682..92dd503 100644 --- a/src/Cocoar.Configuration/Core/TenantPipeline.cs +++ b/src/Cocoar.Configuration/Core/TenantPipeline.cs @@ -41,7 +41,6 @@ internal sealed class TenantPipeline : ILocalStorageHost, IDisposable, IAsyncDis internal ReactiveConfigManager ReactiveConfigManager { get; } internal ReactiveConfigurationFactory ReactiveFactory { get; } internal ConfigurationEngine Engine { get; } - internal MasterBackplane Backplane => State.Backplane; private int _initialized; internal bool IsInitialized => Volatile.Read(ref _initialized) != 0; diff --git a/src/Cocoar.Configuration/Providers/Abstractions/ProviderReactive.cs b/src/Cocoar.Configuration/Providers/Abstractions/ProviderReactive.cs new file mode 100644 index 0000000..d3ec662 --- /dev/null +++ b/src/Cocoar.Configuration/Providers/Abstractions/ProviderReactive.cs @@ -0,0 +1,37 @@ +using Cocoar.Configuration.Reactive.Internal; + +namespace Cocoar.Configuration.Providers.Abstractions; + +/// +/// Factories for the change-notification a custom provider returns from +/// ChangesAsBytes, without taking a dependency on System.Reactive. These are the same primitives the +/// built-in providers use. The Provider prefix avoids colliding with System.Reactive's Observable +/// for consumers that do reference it. +/// +public static class ProviderObservable +{ + /// An observable that never emits and never completes — use when a provider has no change detection. + public static IObservable Never() => ObservableHelpers.Never(); + + /// An observable that completes immediately without emitting. + public static IObservable Empty() => ObservableHelpers.Empty(); + + /// + /// Creates an observable from a subscribe callback. The callback returns an + /// (see ) that tears down any timers/tokens when the subscription ends. + /// + public static IObservable Create(Func, IDisposable> subscribe) + => ObservableHelpers.Create(subscribe); +} + +/// +/// Factories for the a provider's change subscription returns from its subscribe callback. +/// +public static class ProviderDisposable +{ + /// A no-op disposable. + public static IDisposable Empty => DisposableHelpers.Empty; + + /// A disposable that runs exactly once when disposed. + public static IDisposable Create(Action onDispose) => DisposableHelpers.Create(onDispose); +} diff --git a/src/Cocoar.Configuration/Providers/FileSourceProvider/FileSourceRulesExtensions.cs b/src/Cocoar.Configuration/Providers/FileSourceProvider/FileSourceRulesExtensions.cs index 6cbf892..9fef232 100644 --- a/src/Cocoar.Configuration/Providers/FileSourceProvider/FileSourceRulesExtensions.cs +++ b/src/Cocoar.Configuration/Providers/FileSourceProvider/FileSourceRulesExtensions.cs @@ -28,4 +28,17 @@ public static ProviderRuleBuilder optionsFactory(cm).ToQueryOptions(), typeof(T) ); + + /// + /// Creates a file-based configuration rule from a config-aware file path — e.g. a per-tenant path + /// a => $"tenants/{a.Tenant}/db.json". The path is resolved from the accessor on each recompute. + /// + public static ProviderRuleBuilder + FromFile(this TypedProviderBuilder builder, Func pathFactory) + where T : class + => new( + cm => FileSourceRuleOptions.FromFilePath(pathFactory(cm)).ToProviderOptions(), + cm => FileSourceRuleOptions.FromFilePath(pathFactory(cm)).ToQueryOptions(), + typeof(T) + ); } diff --git a/src/Cocoar.Configuration/Secrets/Protectors/Hybrid/HybridProtectorRegistrar.cs b/src/Cocoar.Configuration/Secrets/Protectors/Hybrid/HybridProtectorRegistrar.cs index 5bdf352..476690c 100644 --- a/src/Cocoar.Configuration/Secrets/Protectors/Hybrid/HybridProtectorRegistrar.cs +++ b/src/Cocoar.Configuration/Secrets/Protectors/Hybrid/HybridProtectorRegistrar.cs @@ -76,8 +76,7 @@ private void ApplySingleKidMode(CertificateProtectorConfig config, IConfiguratio config.CertificateComparer, includeSubdirectories: 0); // Flat structure, no subdirectories - // In single-kid mode with the new architecture, we create a kid folder on the fly - // or we need to handle this differently. Actually, let's just register for the single kid. + // Single-kid mode: respond only to the configured kid (plus any explicit additional kids). var protector = new SingleKidProtectorWrapper(inventory, config.ForceSingleKid!, config.AdditionalKids); // Publish the current encryption public key for this single, unambiguous kid. diff --git a/src/tests/Cocoar.Configuration.Providers.Tests/ProviderReactiveTests.cs b/src/tests/Cocoar.Configuration.Providers.Tests/ProviderReactiveTests.cs new file mode 100644 index 0000000..3c0c53b --- /dev/null +++ b/src/tests/Cocoar.Configuration.Providers.Tests/ProviderReactiveTests.cs @@ -0,0 +1,93 @@ +using Cocoar.Configuration.Providers.Abstractions; +using Xunit; + +namespace Cocoar.Configuration.Providers.Tests; + +/// +/// Verifies the public provider-authoring reactive helpers ( / +/// ) behave as documented for a custom provider's +/// ChangesAsBytes stream — without referencing System.Reactive. +/// +public sealed class ProviderReactiveTests +{ + private sealed class RecordingObserver : IObserver + { + public List Values { get; } = new(); + public bool Completed { get; private set; } + public Exception? Error { get; private set; } + public void OnNext(T value) => Values.Add(value); + public void OnCompleted() => Completed = true; + public void OnError(Exception error) => Error = error; + } + + [Fact] + [Trait("Type", "Unit")] + public void Never_DoesNotEmitOrComplete() + { + var observer = new RecordingObserver(); + + using var subscription = ProviderObservable.Never().Subscribe(observer); + + Assert.Empty(observer.Values); + Assert.False(observer.Completed); + Assert.Null(observer.Error); + } + + [Fact] + [Trait("Type", "Unit")] + public void Empty_CompletesWithoutEmitting() + { + var observer = new RecordingObserver(); + + ProviderObservable.Empty().Subscribe(observer); + + Assert.Empty(observer.Values); + Assert.True(observer.Completed); + } + + [Fact] + [Trait("Type", "Unit")] + public void Create_WiresSubscribeCallback_AndReturnsTeardown() + { + var observer = new RecordingObserver(); + var torn = false; + + var observable = ProviderObservable.Create(o => + { + o.OnNext(1); + o.OnNext(2); + return ProviderDisposable.Create(() => torn = true); + }); + + var subscription = observable.Subscribe(observer); + Assert.Equal(new[] { 1, 2 }, observer.Values); + Assert.False(torn); + + subscription.Dispose(); + Assert.True(torn); + } + + [Fact] + [Trait("Type", "Unit")] + public void Disposable_Create_RunsActionExactlyOnce() + { + var count = 0; + var disposable = ProviderDisposable.Create(() => count++); + + disposable.Dispose(); + disposable.Dispose(); + + Assert.Equal(1, count); + } + + [Fact] + [Trait("Type", "Unit")] + public void Disposable_Empty_IsNoOp() + { + var disposable = ProviderDisposable.Empty; + + disposable.Dispose(); // must not throw + + Assert.NotNull(disposable); + } +} diff --git a/website/.vitepress/config.ts b/website/.vitepress/config.ts index c8c4f7f..0c80e77 100644 --- a/website/.vitepress/config.ts +++ b/website/.vitepress/config.ts @@ -111,6 +111,7 @@ export default defineConfig({ { text: 'Overview', link: '/guide/secrets/overview' }, { text: 'Secret & Leases', link: '/guide/secrets/secret-type' }, { text: 'Encryption Setup', link: '/guide/secrets/encryption-setup' }, + { text: 'Publishing Encryption Keys ', link: '/guide/secrets/key-publishing' }, { text: 'CLI Tools', link: '/guide/secrets/cli' }, { text: 'Certificate Caching ', link: '/guide/secrets/certificate-caching' }, { text: 'Security Model ', link: '/guide/secrets/security-model' }, diff --git a/website/changelog.md b/website/changelog.md index 0fe757b..f0437f3 100644 --- a/website/changelog.md +++ b/website/changelog.md @@ -15,6 +15,28 @@ - Secret-typed members cannot be overridden via LocalStorage (throws `NotSupportedException`) - `IProviderServiceRegistration` gained resolve-time factory registration support +**Multi-Tenancy** (ADR-005) +- The same configuration type resolves to different values per tenant, layered on a shared global base +- `.TenantScoped()` rule marker + `Tenant` on `IConfigurationAccessor` — author one flat rule list (no second surface) +- `ITenantConfigurationAccessor` lifecycle: `InitializeTenantAsync` / `EnsureTenantInitializedAsync` / `RemoveTenantAsync` +- Per-tenant access: `GetConfigForTenant` / `GetReactiveConfigForTenant` / `GetFeatureFlagsForTenant` / `GetEntitlementsForTenant` / `GetLocalStorageForTenant` +- Tenant-only types excluded from the global DI plan; per-tenant flags/entitlements need no source-generator change +- ASP.NET Core: scoped `ITenantReactiveConfig` + `ITenantContext`; `MapTenantFeatureFlagEndpoints()` / `MapTenantEntitlementEndpoints()` + +**Service-Backed (DI-aware) configuration** (ADR-006) +- Two-layer model: eager `UseConfiguration` (Layer 1) + lazy `UseServiceBackedConfiguration` (Layer 2), whose provider factories receive the `IServiceProvider` +- `FromStorage((sp, a) => …)`, `FromHttp((sp, a) => …)`, `FromService(s => …)` — use `IHttpClientFactory` / Marten / EF without giving up the no-DI core +- Activated on host start via `IHostedLifecycleService` (a recompute, never a rebuild — live reactive views stay valid) +- Public `ServiceBackedProviderBuilder` seam for third-party `(sp, a)` provider overloads + +**Secrets — encryption-key publishing** +- Publish the public half of the secrets encryption key (`ISecretEncryptionKeyProvider`; ASP.NET Core `MapSecretEncryptionKeyEndpoints()` at `/.well-known/cocoar/encryption-keys`) so a browser/CLI can build `cocoar.secret` envelopes +- `SecretEnvelope` typed secret-overlay writes; LocalStorage `SetSecretAsync` / `SetSecretEnvelopeAsync` accept pre-encrypted envelopes + +**Custom-provider authoring** +- Public `ProviderObservable` / `ProviderDisposable` helpers (in `Cocoar.Configuration.Providers.Abstractions`) for a provider's change stream without referencing System.Reactive +- `FromFile(a => …)` config-aware file-path overload — the natural shape for per-tenant file rules (resolves the path from the accessor per recompute) + ### Changed **Secrets — robust enum & casing handling** diff --git a/website/guide/providers/custom.md b/website/guide/providers/custom.md index 3369ab4..e41027a 100644 --- a/website/guide/providers/custom.md +++ b/website/guide/providers/custom.md @@ -70,15 +70,15 @@ public class DatabaseConfigProvider public override IObservable ChangesAsBytes(DatabaseProviderQuery query) { // No change detection — static until next recompute - return ObservableHelpers.Never(); + return ProviderObservable.Never(); // Or: implement SqlDependency, polling, etc. } } ``` -::: info Helper Utilities -`ObservableHelpers` and `DisposableHelpers` are lightweight utilities included in `Cocoar.Configuration` — no additional package needed. They provide `Never()`, `Empty()`, `Create()` for observables and `Create(Action)` for disposables. +::: info Helper utilities +`ProviderObservable` and `ProviderDisposable` (in `Cocoar.Configuration.Providers.Abstractions`, the same namespace as `ConfigurationProvider`) are public helpers for building a provider's change stream without referencing System.Reactive: `Never()`, `Empty()`, `Create()` for observables and `Empty` / `Create(Action)` for disposables. ::: ## Provider Key and Instance Caching @@ -157,7 +157,7 @@ public sealed class DbConfigProvider(DbConfigOptions options) return json is not null ? Encoding.UTF8.GetBytes(json) : "{}"u8.ToArray(); } - public override IObservable ChangesAsBytes(DbConfigQuery query) => ObservableHelpers.Never(); + public override IObservable ChangesAsBytes(DbConfigQuery query) => ProviderObservable.Never(); } ``` @@ -215,7 +215,7 @@ public override IObservable ChangesAsBytes(DatabaseProviderQuery query) } }, cts.Token); - return DisposableHelpers.Create(() => + return ProviderDisposable.Create(() => { cts.Cancel(); timer.Dispose(); @@ -262,7 +262,7 @@ public class DatabaseConfigProvider public override IObservable ChangesAsBytes(DatabaseProviderQuery query) { - return ObservableHelpers.Never(); // Or implement polling/notifications + return ProviderObservable.Never(); // Or implement polling/notifications } } diff --git a/website/guide/providers/localstorage.md b/website/guide/providers/localstorage.md index 0fd506f..51a9416 100644 --- a/website/guide/providers/localstorage.md +++ b/website/guide/providers/localstorage.md @@ -54,7 +54,7 @@ public class SettingsController(ILocalStorage storage) } ``` -The selector must be a **simple member-access chain** (`x => x.Smtp.Port`). Indexers, method calls, and casts throw `NotSupportedException` — use the raw [overlay surface](#raw-overlay-surface) for dynamic paths. +The selector must be a **simple member-access chain** (`x => x.Smtp.Port`). Indexers and method calls throw `NotSupportedException` (a type cast around the member chain is unwrapped and tolerated) — use the raw [overlay surface](#raw-overlay-surface) for dynamic paths. ::: tip Writes are reactive A write persists to storage, signals the provider, and triggers a (debounced) recompute. Subscribers of `IReactiveConfig` receive the new merged value automatically. @@ -179,4 +179,4 @@ ILocalStorage.SetAsync(x => x.Port, 587) → IReactiveConfig emits the new effective value ``` -The read/merge path is identical to every other provider — LocalStorage only adds the write path. See the runnable [LocalStorageOverride example](https://github.com/) for an end-to-end walkthrough. +The read/merge path is identical to every other provider — LocalStorage only adds the write path. See the runnable [LocalStorageOverride example](https://github.com/cocoar-dev/cocoar.configuration/tree/develop/src/Examples/LocalStorageOverride) for an end-to-end walkthrough. diff --git a/website/guide/secrets/key-publishing.md b/website/guide/secrets/key-publishing.md new file mode 100644 index 0000000..629087d --- /dev/null +++ b/website/guide/secrets/key-publishing.md @@ -0,0 +1,73 @@ +# Publishing Encryption Keys + +Secrets are encrypted with the **public** half of an X.509 certificate and decrypted server-side with the private half (see [Encryption Setup](/guide/secrets/encryption-setup)). To let an **external producer** — a browser form, a CLI, another service — build a `cocoar.secret` envelope your server can later decrypt, you publish the **public key** over an HTTP endpoint. + +Only public-key material is ever exposed. The private key never leaves the server, and no plaintext is reachable through this API. + +## Mapping the endpoints (ASP.NET Core) + +`Cocoar.Configuration.AspNetCore` maps the well-known endpoints: + +```csharp +app.MapSecretEncryptionKeyEndpoints(); // list + by-kid, under /.well-known/cocoar/encryption-keys +``` + +This maps two routes and returns a single `IEndpointConventionBuilder`, so one convention (e.g. `.RequireAuthorization()`) covers both: + +| Route | Returns | +|---|---| +| `GET /.well-known/cocoar/encryption-keys` | `{ "keys": [ … ] }` — the current public key per configured kid (always `200`; empty list when nothing is publishable) | +| `GET /.well-known/cocoar/encryption-keys/{kid}` | the public key for one kid, or `404` ProblemDetails when that kid is not published | + +Map them individually instead if you only need one: + +```csharp +app.MapSecretEncryptionKeys(); // just the list +app.MapSecretEncryptionKeyByKid(); // just the by-kid lookup +``` + +Pass a custom base pattern if the default route doesn't fit: + +```csharp +app.MapSecretEncryptionKeyEndpoints("/keys/cocoar"); +``` + +::: warning Not secured by default +Like `MapFeatureFlagEndpoints`, these routes are **open** unless you secure them. Public keys are safe to expose, but if you want them behind auth, chain `.RequireAuthorization()` — one call on the composite builder covers both routes: + +```csharp +app.MapSecretEncryptionKeyEndpoints().RequireAuthorization(); +``` +::: + +## Response shape + +Each published key is the current public key for one `kid`: + +```json +{ + "kid": "prod-secrets", + "alg": "RSA-OAEP-AES256-GCM", + "walg": "RSA-OAEP-256", + "enc": "AES-256-GCM", + "format": "spki", + "encoding": "base64url", + "publicKey": "" +} +``` + +The list endpoint wraps these as `{ "keys": [ … ] }`. The `keys` field name is pinned, so a host JSON naming policy can't rename it. There is exactly **one current key per kid** — the certificate the decryption engine prefers — and key material is re-read on every request, so certificate rotation is reflected without a restart. + +## How a producer uses it + +1. Fetch the key for the kid it should encrypt to. `alg` / `walg` / `enc` describe the scheme; `publicKey` is the SPKI to import. +2. Generate a random AES-256 DEK, encrypt the value with AES-GCM, wrap the DEK with RSA-OAEP-256, and assemble the `cocoar.secret` envelope (with `kid` stamped from the key). +3. Send the envelope to your server. It is stored as-is and decrypted only on `Secret.Open()`. + +The envelope wire format is documented in [Custom Providers → Secrets](/guide/providers/custom#secrets-in-custom-providers). The same envelope can be written through a LocalStorage overlay via `SetSecretEnvelopeAsync` / `SetSecretAsync`. + +## Availability + +Publishing is available when secrets are configured via [`UseSecretsSetup`](/guide/secrets/encryption-setup) with a single, unambiguous current key (single-kid mode). When nothing is publishable — no secrets configured — the list endpoint returns `{ "keys": [] }` and the by-kid endpoint returns `404`. Multi-kid / folder mode is decrypt-only for now, so it publishes nothing; per-kid (per-tenant) publishing is planned. + +The DI service behind the endpoints is `ISecretEncryptionKeyProvider` (`GetCurrentKeys()` / `GetCurrentKey(kid)`), registered wherever secrets are configured — resolve it directly to build your own endpoint or workflow. From 48f56f5cbf37851ac2ea14dceb598d72259cdeec Mon Sep 17 00:00:00 2001 From: Bernhard Windisch Date: Sat, 30 May 2026 23:10:31 +0200 Subject: [PATCH 05/18] =?UTF-8?q?refactor:=20rename=20LocalStorage=20?= =?UTF-8?q?=E2=86=92=20WritableStore=20(writable,=20backend-agnostic=20sto?= =?UTF-8?q?re)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "LocalStorage" implied local-file-only; the feature is really a writable, backend-agnostic config store (file/DB/HTTP/remote behind a pluggable backend) — the one layer the app can write to at runtime. Renamed across code, tests, docs, ADRs and the example (pre-release, so no shims/data migration needed). Public API: - ILocalStorage → IWritableStore; ILocalStorageOverlay → IWritableStoreOverlay - FromLocalStorage(...) (Layer 1) and FromStorage((sp,a)=>...) (Layer 2) → both FromStore(...) (symmetric, overloaded by builder type, like FromHttp) - IStorageBackend → IStoreBackend; FileStorageBackend → FileStoreBackend - GetLocalStorageForTenant → GetWritableStoreForTenant - OverrideEntry → StoreEntry; IsOverridden → IsSet (drops the misleading "override wins" wording) - namespace Cocoar.Configuration.LocalStorage → Cocoar.Configuration.WritableStore - on-disk default {BaseDirectory}/.cocoar/localStorage/ → /.cocoar/store/ Internal sparse-merge machinery (OverlayPathResolver/SparseOverlayMutator/…) keeps the "overlay" vocabulary — it describes the mechanism, not a precedence claim. Build 0 errors (net8.0+net9.0), 713 tests green, VitePress build clean. Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 20 ++++----- CLAUDE.md | 4 +- README.md | 2 +- .../adr/ADR-005-multi-tenant-configuration.md | 12 +++--- docs/adr/ADR-006-di-aware-configuration.md | 18 ++++---- .../IWritableStore.cs} | 20 ++++----- .../IWritableStoreOverlay.cs} | 12 +++--- .../ServiceBackedConfigurationExtensions.cs | 8 ++-- .../ServiceBackedRulesExtensions.cs | 18 ++++---- .../ServiceDescriptorEmitter.cs | 4 +- .../HttpProviderOptions.cs | 2 +- src/Cocoar.Configuration.slnx | 2 +- .../Core/ConfigManager.cs | 12 +++--- ...alStorageHost.cs => IWritableStoreHost.cs} | 4 +- .../Core/TenantPipeline.cs | 4 +- .../Fluent/RuleBuilderBase.cs | 2 +- .../Fluent/ServiceBackedProviderBuilder.cs | 4 +- .../IProviderServiceRegistration.cs | 2 +- .../LocalStorageProviderQueryOptions.cs | 8 ---- .../FileStoreBackend.cs} | 8 ++-- .../IStoreBackend.cs} | 4 +- .../OverlayPathResolver.cs | 2 +- .../OverlaySerialization.cs | 2 +- .../SparseOverlayMutator.cs | 0 .../TenantWritableStoreExtensions.cs} | 28 ++++++------- .../WritableStoreAdapter.cs} | 26 ++++++------ .../WritableStoreProvider.cs} | 14 +++---- .../WritableStoreProviderOptions.cs} | 20 ++++----- .../WritableStoreProviderQueryOptions.cs | 8 ++++ .../WritableStoreRulesExtensions.cs} | 34 +++++++-------- .../WritableStoreState.cs} | 16 +++---- .../Rules/IRuleManager.cs | 2 +- src/Examples/ServiceBackedConfig/Program.cs | 10 ++--- .../Program.cs | 14 +++---- .../WritableStoreExample.csproj} | 2 +- .../TenantSecretsTests.cs | 10 ++--- ...geTests.cs => TenantWritableStoreTests.cs} | 24 +++++------ .../SecretEncryptionKeyProviderTests.cs | 8 ++-- .../FileStoreBackendTests.cs} | 14 +++---- .../OverlayPathResolverTests.cs | 2 +- .../OverlayTestSupport.cs | 4 +- .../SparseOverlayMutatorTests.cs | 2 +- .../WritableStoreOverlayEndToEndTests.cs} | 20 ++++----- .../WritableStoreSecretEnvelopeTests.cs} | 16 +++---- .../ServiceBackedActivationTests.cs | 12 +++--- .../ServiceBackedReviewRegressionTests.cs | 4 +- .../ServiceBackedStorageTests.cs | 8 ++-- .../TestSupport.cs | 10 ++--- .../ThirdPartyServiceBackedProviderTests.cs | 2 +- website/.vitepress/config.ts | 2 +- website/changelog.md | 18 ++++---- website/guide/di/service-backed.md | 12 +++--- website/guide/multi-tenancy/overview.md | 10 ++--- website/guide/providers/overview.md | 2 +- .../{localstorage.md => writable-store.md} | 42 +++++++++---------- website/guide/secrets/key-publishing.md | 2 +- 56 files changed, 286 insertions(+), 286 deletions(-) rename src/Cocoar.Configuration.Abstractions/{LocalStorage/ILocalStorage.cs => WritableStore/IWritableStore.cs} (86%) rename src/Cocoar.Configuration.Abstractions/{LocalStorage/ILocalStorageOverlay.cs => WritableStore/IWritableStoreOverlay.cs} (89%) rename src/Cocoar.Configuration/Core/{ILocalStorageHost.cs => IWritableStoreHost.cs} (79%) delete mode 100644 src/Cocoar.Configuration/Providers/LocalStorageProvider/LocalStorageProviderQueryOptions.cs rename src/Cocoar.Configuration/Providers/{LocalStorageProvider/FileStorageBackend.cs => WritableStoreProvider/FileStoreBackend.cs} (90%) rename src/Cocoar.Configuration/Providers/{LocalStorageProvider/IStorageBackend.cs => WritableStoreProvider/IStoreBackend.cs} (84%) rename src/Cocoar.Configuration/Providers/{LocalStorageProvider => WritableStoreProvider}/OverlayPathResolver.cs (99%) rename src/Cocoar.Configuration/Providers/{LocalStorageProvider => WritableStoreProvider}/OverlaySerialization.cs (96%) rename src/Cocoar.Configuration/Providers/{LocalStorageProvider => WritableStoreProvider}/SparseOverlayMutator.cs (100%) rename src/Cocoar.Configuration/Providers/{LocalStorageProvider/TenantLocalStorageExtensions.cs => WritableStoreProvider/TenantWritableStoreExtensions.cs} (51%) rename src/Cocoar.Configuration/Providers/{LocalStorageProvider/LocalStorageAdapter.cs => WritableStoreProvider/WritableStoreAdapter.cs} (89%) rename src/Cocoar.Configuration/Providers/{LocalStorageProvider/LocalStorageProvider.cs => WritableStoreProvider/WritableStoreProvider.cs} (52%) rename src/Cocoar.Configuration/Providers/{LocalStorageProvider/LocalStorageProviderOptions.cs => WritableStoreProvider/WritableStoreProviderOptions.cs} (60%) create mode 100644 src/Cocoar.Configuration/Providers/WritableStoreProvider/WritableStoreProviderQueryOptions.cs rename src/Cocoar.Configuration/Providers/{LocalStorageProvider/LocalStorageRulesExtensions.cs => WritableStoreProvider/WritableStoreRulesExtensions.cs} (69%) rename src/Cocoar.Configuration/Providers/{LocalStorageProvider/LocalStorageStore.cs => WritableStoreProvider/WritableStoreState.cs} (85%) rename src/Examples/{LocalStorageOverride => WritableStoreExample}/Program.cs (92%) rename src/Examples/{LocalStorageOverride/LocalStorageOverride.csproj => WritableStoreExample/WritableStoreExample.csproj} (88%) rename src/tests/Cocoar.Configuration.MultiTenant.Tests/{TenantLocalStorageTests.cs => TenantWritableStoreTests.cs} (73%) rename src/tests/Cocoar.Configuration.Providers.Tests/{LocalStorage/FileStorageBackendTests.cs => WritableStore/FileStoreBackendTests.cs} (88%) rename src/tests/Cocoar.Configuration.Providers.Tests/{LocalStorage => WritableStore}/OverlayPathResolverTests.cs (96%) rename src/tests/Cocoar.Configuration.Providers.Tests/{LocalStorage => WritableStore}/OverlayTestSupport.cs (91%) rename src/tests/Cocoar.Configuration.Providers.Tests/{LocalStorage => WritableStore}/SparseOverlayMutatorTests.cs (98%) rename src/tests/Cocoar.Configuration.Providers.Tests/{LocalStorage/LocalStorageOverlayEndToEndTests.cs => WritableStore/WritableStoreOverlayEndToEndTests.cs} (91%) rename src/tests/Cocoar.Configuration.Providers.Tests/{LocalStorage/LocalStorageSecretEnvelopeTests.cs => WritableStore/WritableStoreSecretEnvelopeTests.cs} (88%) rename website/guide/providers/{localstorage.md => writable-store.md} (72%) diff --git a/CHANGELOG.md b/CHANGELOG.md index 44f816b..c1a9827 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,30 +4,30 @@ ### Added -- **LocalStorage provider** — a writable, application-controlled override layer for *overridable defaults*: the normal sources (files, environment, …) supply defaults, and the application overrides individual values at runtime. - - `ILocalStorage` (type-safe facade) and `ILocalStorageOverlay` (raw key-path surface) in `Cocoar.Configuration.Abstractions` +- **WritableStore provider** — a writable, application-controlled override layer for *overridable defaults*: the normal sources (files, environment, …) supply defaults, and the application overrides individual values at runtime. + - `IWritableStore` (type-safe facade) and `IWritableStoreOverlay` (raw key-path surface) in `Cocoar.Configuration.Abstractions` - **Sparse writes** — `SetAsync(x => x.Smtp.Port, value)` persists only the touched leaf; unset keys keep inheriting from the lower layers - `ResetAsync(...)` removes an override (value falls back to the inherited default); an explicit `null` override is distinct from reset - - `DescribeAsync()` returns per-key provenance (`OverrideEntry`: base value, effective value, `IsOverridden`) for management UIs - - `.FromLocalStorage()` rule extension; file-based backend by default with a pluggable `IStorageBackend` - - `ILocalStorage` / `ILocalStorageOverlay` are DI-injectable (single shared singleton) — write your own endpoints with your own validation/normalization/logging - - LocalStorageOverride example project + - `DescribeAsync()` returns per-key provenance (`StoreEntry`: base value, effective value, `IsSet`) for management UIs + - `.FromStore()` rule extension; file-based backend by default with a pluggable `IStoreBackend` + - `IWritableStore` / `IWritableStoreOverlay` are DI-injectable (single shared singleton) — write your own endpoints with your own validation/normalization/logging + - WritableStoreExample example project - `IProviderServiceRegistration` now supports resolve-time factory registrations (`ProviderServiceRegistration.Singleton(type, factory)`) in addition to eager instances - **Multi-Tenancy** — the same configuration type resolves to different values per tenant, layered on a shared global base (ADR-005) - `ITenantConfigurationAccessor` lifecycle on `ConfigManager`: `InitializeTenantAsync` / `EnsureTenantInitializedAsync` / `IsTenantInitialized` / `RemoveTenantAsync` - `.TenantScoped()` rule marker + `Tenant` on `IConfigurationAccessor` (default-interface member, non-breaking) — author one flat rule list, no second surface - - Per-tenant access: `GetConfigForTenant` / `GetReactiveConfigForTenant` / `GetFeatureFlagsForTenant` / `GetEntitlementsForTenant` / `GetLocalStorageForTenant` + - Per-tenant access: `GetConfigForTenant` / `GetReactiveConfigForTenant` / `GetFeatureFlagsForTenant` / `GetEntitlementsForTenant` / `GetWritableStoreForTenant` - Tenant-only types are excluded from the global DI plan (avoids the captive-dependency bug); per-tenant flags/entitlements need no source-generator change - ASP.NET Core: scoped `ITenantReactiveConfig` + `ITenantContext`, and `MapTenantFeatureFlagEndpoints()` / `MapTenantEntitlementEndpoints()` - **Service-Backed (DI-aware) configuration** — a two-layer model so config providers can use DI-managed services (ADR-006) - `UseServiceBackedConfiguration(...)` (DI package) — Layer-2 rules whose provider factories receive the application `IServiceProvider` - - `FromStorage((sp, a) => IStorageBackend)`, `FromHttp((sp, a) => HttpClient)`, and `FromService(s => config)` overloads + - `FromStore((sp, a) => IStoreBackend)`, `FromHttp((sp, a) => HttpClient)`, and `FromService(s => config)` overloads - providers can use `IHttpClientFactory` / Marten / EF without giving up the no-DI core; activated on host start via `IHostedLifecycleService` (a recompute, never a rebuild) - public `ServiceBackedProviderBuilder` seam so third-party provider packages can author their own `(sp, a)` overloads - ServiceBackedConfig example project - **Secrets encryption-key publishing** — publish the public half of the configured secrets encryption key so a browser/CLI producer can build `cocoar.secret` envelopes - `ISecretEncryptionKeyProvider` (DI) and ASP.NET Core `MapSecretEncryptionKeyEndpoints()` under `/.well-known/cocoar/encryption-keys` - - `SecretEnvelope` for typed secret-overlay writes; LocalStorage `SetSecretAsync` / `SetSecretEnvelopeAsync` accept pre-encrypted envelopes + - `SecretEnvelope` for typed secret-overlay writes; WritableStore `SetSecretAsync` / `SetSecretEnvelopeAsync` accept pre-encrypted envelopes - Public `ProviderObservable` / `ProviderDisposable` helpers (in `Cocoar.Configuration.Providers.Abstractions`) for authoring a custom provider's change stream without referencing System.Reactive - `FromFile(a => …)` config-aware file-path overload (resolves the path from the accessor per recompute) — the natural shape for per-tenant file rules @@ -37,7 +37,7 @@ ### Notes -- Secret-typed members (`Secret` / `ISecret`) cannot be overridden via LocalStorage — the typed facade throws `NotSupportedException` (manage secrets via the Secrets CLI/provider). +- Secret-typed members (`Secret` / `ISecret`) cannot be overridden via WritableStore — the typed facade throws `NotSupportedException` (manage secrets via the Secrets CLI/provider). - Overlay values serialize with vanilla options (enums as strings) and overlay keys are aligned to the lower layers' casing, so an override **replaces** the base key rather than creating a casing-variant sibling. ## [5.0.0] - 2026-03-24 diff --git a/CLAUDE.md b/CLAUDE.md index b10fb28..7933945 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -36,7 +36,7 @@ dotnet pack ./src -c Release - **ConfigManager** (`src/Cocoar.Configuration/Core/`) - Central orchestrator that manages configuration lifecycle, rule execution, and reactive updates. Always created via `ConfigManager.Create()` or `ConfigManager.CreateAsync()` — constructors are internal. Both take an `Action` lambda and return a fully initialized `ConfigManager`. - **ConfigManagerBuilder** (`src/Cocoar.Configuration/Core/`) - Fluent builder received as parameter in `Create`/`CreateAsync` lambdas. Satellite libraries extend it via extension methods (e.g. `.UseSecretsSetup()`, `.UseFeatureFlags()`, `.UseEntitlements()`). - **Feature Flags & Entitlements** (`src/Cocoar.Configuration/Flags/`) - Source-generated pattern: `partial class` implements `IFeatureFlags` or `IEntitlements`, generator produces constructor and `Config` property (reads `IReactiveConfig.CurrentValue`). Multi-config via tuples (`IFeatureFlags<(T1, T2)>`). These interfaces are the only supported way to define flags and entitlements. -- **Providers** (`src/Cocoar.Configuration/Providers/`) - Abstract configuration sources (File, Environment, CommandLine, HTTP, Static, Observable, LocalStorage) +- **Providers** (`src/Cocoar.Configuration/Providers/`) - Abstract configuration sources (File, Environment, CommandLine, HTTP, Static, Observable, WritableStore) - **Fluent Builders** (`src/Cocoar.Configuration/Fluent/`) - `RulesBuilder` for defining configuration rules with `.For().FromFile()` pattern. `TypedRuleBuilder` has a `where T : class` constraint — configuration types must be reference types. - **SetupBuilder** (`src/Cocoar.Configuration/Configure/`) - DI registration with `.ConcreteType()` and `.Interface()` patterns - **Multi-Tenancy** (`src/Cocoar.Configuration/Core/TenantPipeline.cs`) - One `ConfigManager` owns a global `TenantPipeline` plus a per-tenant registry on a shared global base. Author one flat rule list with `.TenantScoped()`; `Tenant` on `IConfigurationAccessor`; consume via `…ForTenant(id)` on `ITenantConfigurationAccessor` (explicit, never DI-injected). See ADR-005. @@ -85,7 +85,7 @@ SetupDefinition.GetComposer(builder).Add(new ServiceLifetimeCapability(...)); | Project | Purpose | |---------|---------| | `Cocoar.Configuration.Abstractions` | Lightweight interfaces (`IConfigurationAccessor`, `IReactiveConfig`, `ISecret`, `SecretLease`) | -| `Cocoar.Configuration` | Main library: providers (incl. LocalStorage writable overlay), builders, reactive engine, multi-tenancy, secrets (`Secret`, X.509 encryption), feature flags, entitlements | +| `Cocoar.Configuration` | Main library: providers (incl. the writable store overlay), builders, reactive engine, multi-tenancy, secrets (`Secret`, X.509 encryption), feature flags, entitlements | | `Cocoar.Configuration.DI` | `AddCocoarConfiguration()` for Microsoft.Extensions.DI (no ASP.NET Core dependency); service-backed (Layer-2) configuration via `UseServiceBackedConfiguration` | | `Cocoar.Configuration.AspNetCore` | ASP.NET Core integration, health endpoints, feature flag/entitlement REST endpoints (incl. per-tenant), secrets encryption-key endpoints, scoped tenant config adapter | | `Cocoar.Configuration.Http` | Remote config provider (polling, SSE, one-time fetch) | diff --git a/README.md b/README.md index 7e97949..a7e229e 100644 --- a/README.md +++ b/README.md @@ -63,7 +63,7 @@ app.Run(); | Environment Variables | `.FromEnvironment("PREFIX_")` | Core | | Command Line | `.FromCommandLine("--prefix")` | Core | | Static / Observable | `.FromStaticJson()` / `.FromObservable()` | Core | -| LocalStorage (writable overlay) | `.FromLocalStorage()` | Core | +| WritableStore (writable overlay) | `.FromStore()` | Core | | 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/docs/adr/ADR-005-multi-tenant-configuration.md b/docs/adr/ADR-005-multi-tenant-configuration.md index 73b5e1f..bb1aec4 100644 --- a/docs/adr/ADR-005-multi-tenant-configuration.md +++ b/docs/adr/ADR-005-multi-tenant-configuration.md @@ -4,7 +4,7 @@ **Date:** 2026-05-29 (updated 2026-05-30) **Decision Makers:** Core Team **Type:** Feature / Architecture -**Related:** ADR-001 (Capabilities), ADR-002 (Atomic Reactive Updates), ADR-004 (Aggregate Rules), PR #47 (LocalStorage sparse override overlay), Secrets encryption-key publishing +**Related:** ADR-001 (Capabilities), ADR-002 (Atomic Reactive Updates), ADR-004 (Aggregate Rules), PR #47 (WritableStore sparse override overlay), Secrets encryption-key publishing --- @@ -51,11 +51,11 @@ services.AddCocoarConfiguration(c => c.UseConfiguration(rules => // Global base for a type that is ALSO tenant-overridable: rules.For().FromStaticJson(smtpDefaults), - rules.For().FromLocalStorage(), // global app-override + rules.For().FromStore(), // global app-override // Tenant-scoped overlays — same flat list, marked .TenantScoped(); the id flows via the accessor: rules.For().FromFile(a => $"tenants/{a.Tenant}/smtp.json").TenantScoped(), - rules.For().FromLocalStorage((a, _) => BackendFor(a.Tenant)).TenantScoped(), // per-tenant backend + rules.For().FromStore((a, _) => BackendFor(a.Tenant)).TenantScoped(), // per-tenant backend ])); ``` @@ -94,7 +94,7 @@ Tenant-scoped values are obtained by **passing the tenant id**, never by DI inje ```csharp var smtp = mgr.GetConfigForTenant(tenantId); // sync var live = mgr.GetReactiveConfigForTenant(tenantId); -var store = mgr.GetLocalStorageForTenant(tenantId); // per-tenant write facade +var store = mgr.GetWritableStoreForTenant(tenantId); // per-tenant write facade var flags = mgr.GetFeatureFlagsForTenant(tenantId); var ents = mgr.GetEntitlementsForTenant(tenantId); ``` @@ -114,7 +114,7 @@ Each tenant snapshot layers on the global base, so a change to the **global** ba The tenant dimension is unified by the factory + bundle: - **Feature Flags / Entitlements** become tenant-aware **without a source-generator change**: the generated flag class already reads an injected `IReactiveConfig`; tenant-awareness means constructing it with the **tenant's** `IReactiveConfig`. `GetFeatureFlagsForTenant(id)` is a per-`(tenant, TFlags)` factory/cache over the existing generated class. The context-aware evaluator and the REST endpoints (`MapFeatureFlagEndpoints`) gain a tenant dimension (e.g. a route segment). -- **LocalStorage** per tenant: reads fall out of the factory (`FromLocalStorage(BackendFor(tenant))`; file backend = a folder per tenant); writes go through a per-tenant `GetLocalStorageForTenant(id)` facade pointing at the tenant's backend. +- **WritableStore** per tenant: reads fall out of the factory (`FromStore(BackendFor(tenant))`; file backend = a folder per tenant); writes go through a per-tenant `GetWritableStoreForTenant(id)` facade pointing at the tenant's backend. - **Secrets** are already tenant-capable via folder mode (`kid` = tenant subfolder routes decryption); a tenant writes its encrypted envelope to its own backend, decrypted with its own cert. ### 8. No-DI core preserved @@ -187,7 +187,7 @@ All rejected in favor of *one flat rule list + per-rule `.TenantScoped()` + `Ten ## References -- PR #47 — LocalStorage sparse override overlay (`ConfigManager.BuildBaseJson`, `MutableJsonMerge`) — the merge/overlay foundation reused here +- PR #47 — WritableStore sparse override overlay (`ConfigManager.BuildBaseJson`, `MutableJsonMerge`) — the merge/overlay foundation reused here - `src/Cocoar.Configuration/Core/ConfigurationEngine.cs` — recompute pipeline (per-instance semaphore + scheduler) - `src/Cocoar.Configuration/Core/MasterBackplane.cs` — `SnapshotStream` (fan-out hook), per-instance publish/dispose - `src/Cocoar.Configuration/Core/ConfigManager.cs` — current single-pipeline ownership to be extended diff --git a/docs/adr/ADR-006-di-aware-configuration.md b/docs/adr/ADR-006-di-aware-configuration.md index b60a56c..b3e0298 100644 --- a/docs/adr/ADR-006-di-aware-configuration.md +++ b/docs/adr/ADR-006-di-aware-configuration.md @@ -4,11 +4,11 @@ **Date:** 2026-05-30 **Decision Makers:** Core Team **Type:** Feature / Architecture -**Related:** ADR-005 (multi-tenancy), the "No-DI core" principle (CLAUDE.md), Microsoft `IConfiguration`/`IOptions`, the HTTP/LocalStorage/Marten provider discussion +**Related:** ADR-005 (multi-tenancy), the "No-DI core" principle (CLAUDE.md), Microsoft `IConfiguration`/`IOptions`, the HTTP/WritableStore/Marten provider discussion -> **Implementation note (delivered).** Shipped as `UseServiceBackedConfiguration` (Layer 2) + `FromStorage((sp,a)=>IStorageBackend)` (DI package) + `FromHttp((sp,a)=>HttpClient)` (Http package), activated by `ServiceBackedConfigurationActivator : IHostedLifecycleService` and the manual `IServiceProvider.ActivateServiceBackedConfigurationAsync()`. The sp-gate is a dedicated, non-clobberable `ConfigRuleOptions.ActivationGate` enforced in `RuleManager.ShouldSkip` (mirrors the `.TenantScoped()` marker — fluent-order-proof). Activation wiring lives in the DI **instance** overload `AddCocoarConfiguration(IServiceCollection, ConfigManager)` — the single point all entry paths (DI, AspNetCore, manual) funnel through. +> **Implementation note (delivered).** Shipped as `UseServiceBackedConfiguration` (Layer 2) + `FromStore((sp,a)=>IStoreBackend)` (DI package) + `FromHttp((sp,a)=>HttpClient)` (Http package), activated by `ServiceBackedConfigurationActivator : IHostedLifecycleService` and the manual `IServiceProvider.ActivateServiceBackedConfigurationAsync()`. The sp-gate is a dedicated, non-clobberable `ConfigRuleOptions.ActivationGate` enforced in `RuleManager.ShouldSkip` (mirrors the `.TenantScoped()` marker — fluent-order-proof). Activation wiring lives in the DI **instance** overload `AddCocoarConfiguration(IServiceCollection, ConfigManager)` — the single point all entry paths (DI, AspNetCore, manual) funnel through. -> The `(sp,a)` overloads are **type-scoped, not ambient**: `UseServiceBackedConfiguration(rules => …)` hands each `rules.For()` a public `ServiceBackedProviderBuilder : TypedProviderBuilder` carrying a public `ServiceBackedRuleContext` (`IsActive` + `ServiceProvider`). `FromStorage`/`FromHttp((sp,a)=>…)` are extensions on *that* type, so using them in Layer-1 `UseConfiguration` is a **compile error**, not a runtime throw. The seam is **public**: a third-party provider package authors its own `FromX((sp,a)=>…)` extension on `ServiceBackedProviderBuilder` (read `Context.ServiceProvider`, gate with the public `WithActivationGate(_ => Context.IsActive)`) and exposes a slot for the resolved artifact on its provider options. The provider class (`ConfigurationProvider<,>`) stays DI-free. Whether a provider is service-backable is the provider author's choice. §11 (scoped `ITenantReactiveConfig` + `ITenantContext`) shipped in `Cocoar.Configuration.AspNetCore`. Covered by `Cocoar.Configuration.ServiceBacked.Tests` + AspNetCore tenant-adapter tests. See "Open questions" below for the resolved decisions. +> The `(sp,a)` overloads are **type-scoped, not ambient**: `UseServiceBackedConfiguration(rules => …)` hands each `rules.For()` a public `ServiceBackedProviderBuilder : TypedProviderBuilder` carrying a public `ServiceBackedRuleContext` (`IsActive` + `ServiceProvider`). `FromStore`/`FromHttp((sp,a)=>…)` are extensions on *that* type, so using them in Layer-1 `UseConfiguration` is a **compile error**, not a runtime throw. The seam is **public**: a third-party provider package authors its own `FromX((sp,a)=>…)` extension on `ServiceBackedProviderBuilder` (read `Context.ServiceProvider`, gate with the public `WithActivationGate(_ => Context.IsActive)`) and exposes a slot for the resolved artifact on its provider options. The provider class (`ConfigurationProvider<,>`) stays DI-free. Whether a provider is service-backable is the provider author's choice. §11 (scoped `ITenantReactiveConfig` + `ITenantContext`) shipped in `Cocoar.Configuration.AspNetCore`. Covered by `Cocoar.Configuration.ServiceBacked.Tests` + AspNetCore tenant-adapter tests. See "Open questions" below for the resolved decisions. --- @@ -72,7 +72,7 @@ services.AddCocoarConfiguration(c => c [ rule.For().FromHttp((sp, a) => sp.GetRequiredService().CreateClient("cocoar-config"), "logging.json"), - rule.For().FromStorage((sp, a) => + rule.For().FromStore((sp, a) => new MartenConfigBackend(sp.GetRequiredService(), a.Tenant)).TenantScoped(), ])); ``` @@ -87,7 +87,7 @@ services.AddCocoarConfiguration(c => c Three pieces, **all in the DI package**: 1. **`ServiceProviderHolder`** (DI-package singleton): `null` until the container is built; afterward holds the **root** `IServiceProvider`. -2. **`sp`-using factory overloads** (`FromStorage((sp,a)=>…)`, `FromHttp` with `IHttpClientFactory`, …): each wraps a core provider-options factory `accessor => userFactory(holder.ServiceProvider!, accessor)` **and** composes a gate `.When(_ => holder.HasServiceProvider)`. The gate reuses the `ShouldSkip` machinery hardened in ADR-005 (a rule that skips while its precondition is absent, contributing nothing). +2. **`sp`-using factory overloads** (`FromStore((sp,a)=>…)`, `FromHttp` with `IHttpClientFactory`, …): each wraps a core provider-options factory `accessor => userFactory(holder.ServiceProvider!, accessor)` **and** composes a gate `.When(_ => holder.HasServiceProvider)`. The gate reuses the `ShouldSkip` machinery hardened in ADR-005 (a rule that skips while its precondition is absent, contributing nothing). 3. **An activation `IHostedService`** (registered by the DI package's `AddCocoarConfiguration`, where the `IServiceCollection` is available; it is container-constructed so it receives `sp`): on host start it sets `holder.ServiceProvider = sp` and triggers a **recompute from the Layer-2 start index**. **Core touch is minimal:** reuse `ShouldSkip` (the `sp`-gate is expressed via the existing `When` predicate) and the already-internal `ScheduleRecompute(startIndex)` + `RestorePrefixContributions`. The only likely new core seam is a small **internal hook to append satellite-supplied rules** to the builder (consistent with how satellites already extend it). `InternalsVisibleTo("Cocoar.Configuration.DI")` already exists, so the DI package can drive the post-container recompute. @@ -115,7 +115,7 @@ Two independent axes; the layer is chosen by `sp`-need, **not** by tenancy: | | no `sp` (Layer 1) | needs `sp` (Layer 2) | |---|---|---| | **global** | `FromFile("app.json")` | `FromHttp((sp,a)=>factory…)` | -| **tenant** | `FromFile(a=>$"t/{a.Tenant}/db.json").TenantScoped()` *(works today)* | `FromStorage((sp,a)=>new Marten(store,a.Tenant)).TenantScoped()` | +| **tenant** | `FromFile(a=>$"t/{a.Tenant}/db.json").TenantScoped()` *(works today)* | `FromStore((sp,a)=>new Marten(store,a.Tenant)).TenantScoped()` | `.TenantScoped()` is a layer-agnostic modifier, valid in **both** methods. The gates **compose**: `.TenantScoped()` adds a "tenant present" gate; Layer 2 adds an "`sp` present" gate. **Marten-per-tenant** = both gates → runs only in a tenant pipeline post-container. Do **not** restrict tenant rules to Layer 2 — that would couple tenancy to DI and kill no-DI multi-tenant scenarios (file-per-tenant in a CLI / embedded lib). @@ -186,7 +186,7 @@ Plus the §11 trap: never re-register `IReactiveConfig` as scoped. | Core `RuleManager.ShouldSkip` | reused for the `sp`-gate (via the existing `When` predicate) — no change needed | Reuse | | Core `ConfigurationEngine.ScheduleRecompute(startIndex)` + `RestorePrefixContributions` | reused to run the Layer-2 activation recompute | Reuse | | Core `ConfigManagerBuilder` | likely **one small internal hook** to append satellite-supplied rules | Additive (internal) | -| **NEW** `Cocoar.Configuration.DI`: `ServiceProviderHolder` + `UseServiceBackedConfiguration` extension + `sp`-aware factory overloads (`FromStorage`, …) + activation `IHostedService` | the whole Layer-2 mechanism | **New (satellite)** | +| **NEW** `Cocoar.Configuration.DI`: `ServiceProviderHolder` + `UseServiceBackedConfiguration` extension + `sp`-aware factory overloads (`FromStore`, …) + activation `IHostedService` | the whole Layer-2 mechanism | **New (satellite)** | | `Cocoar.Configuration.Http` | `FromHttp((sp,a)=>…)` overload resolving `IHttpClientFactory` | Additive | | (Future) `Cocoar.Configuration.AspNetCore` | scoped `ITenantReactiveConfig` (§11) | Additive | @@ -212,10 +212,10 @@ Plus the §11 trap: never re-register `IReactiveConfig` as scoped. ## Open questions — resolved in the implementation - **Gating granularity:** ✅ per-`sp`-usage. Each `sp`-using overload attaches a dedicated `ActivationGate`; a non-`sp` rule placed in Layer 2 runs eagerly and still wins by position. -- **Naming:** ✅ `UseServiceBackedConfiguration`; factory overloads `FromStorage((sp,a)=>IStorageBackend)` and `FromHttp((sp,a)=>HttpClient)`. +- **Naming:** ✅ `UseServiceBackedConfiguration`; factory overloads `FromStore((sp,a)=>IStoreBackend)` and `FromHttp((sp,a)=>HttpClient)`. - **Activation hook:** ✅ `IHostedLifecycleService`, acting in `StartingAsync` (before any regular `IHostedService.StartAsync`), so Layer 2 is live before app/hosted-service code reads config. A manual `IServiceProvider.ActivateServiceBackedConfigurationAsync()` covers non-host scenarios; both are idempotent (the holder publishes the provider exactly once). Consumers that read a snapshot *during* container build see the Layer-1 base; the readiness contract (§7) requires a **subscription** to receive the upgrade. - **Append-rules core seam:** ✅ `ConfigManagerBuilder.AddServiceBackedRules(IEnumerable)` appends after Layer 1 and records `ConfigManager.ServiceBackedLayerStartIndex`. The sp-gate seam is the **public**, **type-scoped** (not ambient) `ServiceBackedRuleContext` (BCL `IServiceProvider` only — the core never names a DI type): it is carried by the public `ServiceBackedProviderBuilder.Context` and read by the DI, Http, and third-party `(sp,a)` overloads. -- **DB change-detection:** still out of scope here — poll (via `FromStorage` on a polling backend) or app-driven re-init (`RemoveTenantAsync` then `InitializeTenantAsync`; there is no in-place reload). Push (`LISTEN/NOTIFY`) remains separate, future work. +- **DB change-detection:** still out of scope here — poll (via `FromStore` on a polling backend) or app-driven re-init (`RemoveTenantAsync` then `InitializeTenantAsync`; there is no in-place reload). Push (`LISTEN/NOTIFY`) remains separate, future work. --- diff --git a/src/Cocoar.Configuration.Abstractions/LocalStorage/ILocalStorage.cs b/src/Cocoar.Configuration.Abstractions/WritableStore/IWritableStore.cs similarity index 86% rename from src/Cocoar.Configuration.Abstractions/LocalStorage/ILocalStorage.cs rename to src/Cocoar.Configuration.Abstractions/WritableStore/IWritableStore.cs index fa89a4f..d9774f5 100644 --- a/src/Cocoar.Configuration.Abstractions/LocalStorage/ILocalStorage.cs +++ b/src/Cocoar.Configuration.Abstractions/WritableStore/IWritableStore.cs @@ -2,12 +2,12 @@ using System.Text.Json; using Cocoar.Configuration.Secrets.SecretTypes; -namespace Cocoar.Configuration.LocalStorage; +namespace Cocoar.Configuration.WritableStore; /// -/// Type-safe facade for a LocalStorage override layer over configuration type . +/// Type-safe facade for a WritableStore override layer over configuration type . /// -/// LocalStorage supplies overridable defaults: the normal sources (files, environment, …) provide +/// WritableStore supplies overridable defaults: the normal sources (files, environment, …) provide /// defaults, and the application overrides individual values at runtime. Writes are sparse — only /// the keys you set are persisted, everything else continues to inherit from the lower layers. A write /// triggers the normal recompute, so IReactiveConfig<T> emits the new effective value. @@ -18,7 +18,7 @@ namespace Cocoar.Configuration.LocalStorage; /// /// /// The configuration type this overlay targets. -public interface ILocalStorage where T : class +public interface IWritableStore where T : class { /// /// Overrides a single value selected by a member-access expression (e.g. x => x.Smtp.Port), @@ -65,23 +65,23 @@ public interface ILocalStorage where T : class /// without this overlay) and the overlay, each annotated with its base value, effective value, and whether /// it is currently overridden. /// - Task> DescribeAsync(CancellationToken ct = default); + Task> DescribeAsync(CancellationToken ct = default); /// /// The raw, key-path overlay surface for dynamic or non-expressible paths. /// - ILocalStorageOverlay Overlay { get; } + IWritableStoreOverlay Overlay { get; } } /// -/// Per-leaf provenance entry produced by . +/// Per-leaf provenance entry produced by . /// /// Dotted leaf path (e.g. "Smtp.Port"). /// The value from the lower layers, without this overlay; if absent there. /// The merged/effective value seen by the application; if absent. -/// when the overlay supplies this key. -public sealed record OverrideEntry( +/// when the overlay supplies this key. +public sealed record StoreEntry( string KeyPath, JsonElement? BaseValue, JsonElement? EffectiveValue, - bool IsOverridden); + bool IsSet); diff --git a/src/Cocoar.Configuration.Abstractions/LocalStorage/ILocalStorageOverlay.cs b/src/Cocoar.Configuration.Abstractions/WritableStore/IWritableStoreOverlay.cs similarity index 89% rename from src/Cocoar.Configuration.Abstractions/LocalStorage/ILocalStorageOverlay.cs rename to src/Cocoar.Configuration.Abstractions/WritableStore/IWritableStoreOverlay.cs index 681c390..72289bf 100644 --- a/src/Cocoar.Configuration.Abstractions/LocalStorage/ILocalStorageOverlay.cs +++ b/src/Cocoar.Configuration.Abstractions/WritableStore/IWritableStoreOverlay.cs @@ -1,24 +1,24 @@ using System.Text.Json.Nodes; -namespace Cocoar.Configuration.LocalStorage; +namespace Cocoar.Configuration.WritableStore; /// -/// Raw, key-path patch surface for a LocalStorage override layer. +/// Raw, key-path patch surface for a WritableStore override layer. /// -/// LocalStorage contributes a sparse overlay: only the leaf keys it explicitly contains +/// WritableStore contributes a sparse overlay: only the leaf keys it explicitly contains /// override the lower configuration layers (files, environment, …). Keys that are absent from the /// overlay inherit their value from those lower layers. This interface is the dependency-free -/// (JsonNode-based) escape hatch used by the typed facade and by +/// (JsonNode-based) escape hatch used by the typed facade and by /// callers that need to address dynamic / non-expressible paths. /// /// /// Segments of the key path must match the JSON property names used by the lower layers. /// The typed facade resolves these automatically; when using this raw surface directly, the caller is -/// responsible for the names. Do not use this surface for secret-typed paths — see . +/// responsible for the names. Do not use this surface for secret-typed paths — see . /// /// /// The configuration type this overlay targets. -public interface ILocalStorageOverlay where T : class +public interface IWritableStoreOverlay where T : class { /// /// Sets a sparse, dotted key path (e.g. "Smtp.Port") to a JSON value, persisting only that leaf. diff --git a/src/Cocoar.Configuration.DI/ServiceBacked/ServiceBackedConfigurationExtensions.cs b/src/Cocoar.Configuration.DI/ServiceBacked/ServiceBackedConfigurationExtensions.cs index 4265c7f..15a8dd7 100644 --- a/src/Cocoar.Configuration.DI/ServiceBacked/ServiceBackedConfigurationExtensions.cs +++ b/src/Cocoar.Configuration.DI/ServiceBacked/ServiceBackedConfigurationExtensions.cs @@ -7,7 +7,7 @@ namespace Cocoar.Configuration.DI; /// /// The DI-package authoring surface for service-backed (Layer-2, ADR-006) configuration: rules whose provider -/// factories receive the application (e.g. FromStorage, +/// factories receive the application (e.g. FromStore, /// FromHttp((sp,a)=>…)). Layer 1 (UseConfiguration) stays eager and DI-free; Layer 2 is lazy /// and container-owned, activated on host start. /// @@ -30,7 +30,7 @@ public static class ServiceBackedConfigurationExtensions /// rules.For<LogConfig>().FromHttp( /// (sp, a) => sp.GetRequiredService<IHttpClientFactory>().CreateClient("cocoar-config"), /// "logging.json", pollInterval: TimeSpan.FromSeconds(30)), - /// rules.For<TenantSettings>().FromStorage( + /// rules.For<TenantSettings>().FromStore( /// (sp, a) => new MartenConfigBackend(sp.GetRequiredService<IDocumentStore>(), a.Tenant)) /// .TenantScoped() /// ])); @@ -38,7 +38,7 @@ public static class ServiceBackedConfigurationExtensions /// /// The configuration builder. /// A function that builds the service-backed rules using the fluent API; its sp-using - /// factories (FromStorage, FromHttp((sp,a)=>…)) read the container at recompute time. + /// factories (FromStore, FromHttp((sp,a)=>…)) read the container at recompute time. public static ConfigManagerBuilder UseServiceBackedConfiguration( this ConfigManagerBuilder builder, Func rules) @@ -51,7 +51,7 @@ public static ConfigManagerBuilder UseServiceBackedConfiguration( // The context exposes the activation signal + provider as late-bound values over the shared holder. It is // carried by the ServiceBackedProviderBuilder handed to each For() and read by the sp-using overloads - // (FromStorage / FromHttp((sp,a)=>…) / third-party ones) — no ambient state. + // (FromStore / FromHttp((sp,a)=>…) / third-party ones) — no ambient state. var context = new ServiceBackedRuleContext( isActive: () => holder.HasServiceProvider, serviceProvider: () => holder.ServiceProvider!); diff --git a/src/Cocoar.Configuration.DI/ServiceBacked/ServiceBackedRulesExtensions.cs b/src/Cocoar.Configuration.DI/ServiceBacked/ServiceBackedRulesExtensions.cs index d1f1253..d9b5b8f 100644 --- a/src/Cocoar.Configuration.DI/ServiceBacked/ServiceBackedRulesExtensions.cs +++ b/src/Cocoar.Configuration.DI/ServiceBacked/ServiceBackedRulesExtensions.cs @@ -11,10 +11,10 @@ namespace Cocoar.Configuration.DI; public static class ServiceBackedRulesExtensions { /// - /// Creates a service-backed storage rule whose is built from the + /// Creates a service-backed storage rule whose is built from the /// application container — e.g. a Marten/EF backend over a DI-managed IDocumentStore / - /// IDbContextFactory<T>. Reuses the (tenant-keyed) LocalStorage backend pipeline, so it also - /// exposes the ILocalStorage<T> write facade and composes with .TenantScoped() for + /// IDbContextFactory<T>. Reuses the (tenant-keyed) WritableStore backend pipeline, so it also + /// exposes the IWritableStore<T> write facade and composes with .TenantScoped() for /// per-tenant, DB-backed configuration. /// /// @@ -26,11 +26,11 @@ public static class ServiceBackedRulesExtensions /// The typed provider builder. /// Factory receiving the root and the current /// (its Tenant is set in a tenant pipeline) and returning the - /// to read configuration from. - public static ProviderRuleBuilder - FromStorage( + /// to read configuration from. + public static ProviderRuleBuilder + FromStore( this ServiceBackedProviderBuilder builder, - Func backendFactory) + Func backendFactory) where T : class { ArgumentNullException.ThrowIfNull(builder); @@ -38,10 +38,10 @@ public static ProviderRuleBuilder backendFactory(context.ServiceProvider, accessor)); // sp-gate: dormant until the container is built; composes (AND) with any .TenantScoped() the caller adds, diff --git a/src/Cocoar.Configuration.DI/ServiceDescriptorEmitter.cs b/src/Cocoar.Configuration.DI/ServiceDescriptorEmitter.cs index d3559fc..ea1a5af 100644 --- a/src/Cocoar.Configuration.DI/ServiceDescriptorEmitter.cs +++ b/src/Cocoar.Configuration.DI/ServiceDescriptorEmitter.cs @@ -40,8 +40,8 @@ public static void Emit( /// /// Discovers provider options that implement and applies the - /// extra service registrations they contribute (e.g. LocalStorage's ILocalStorage<T> / - /// ILocalStorageOverlay<T>). Registrations may be eager singleton instances or resolve-time + /// extra service registrations they contribute (e.g. WritableStore's IWritableStore<T> / + /// IWritableStoreOverlay<T>). Registrations may be eager singleton instances or resolve-time /// factories. Collisions on the same service type are last-rule-wins; emission is ordered by service type /// full name for determinism. /// diff --git a/src/Cocoar.Configuration.Http/HttpProviderOptions.cs b/src/Cocoar.Configuration.Http/HttpProviderOptions.cs index 91e0aa2..369691f 100644 --- a/src/Cocoar.Configuration.Http/HttpProviderOptions.cs +++ b/src/Cocoar.Configuration.Http/HttpProviderOptions.cs @@ -74,7 +74,7 @@ public HttpProviderOptions( /// A service-backed client (from IHttpClientFactory) is externally owned and per-rule — two rules with /// distinct clients must never collapse onto one shared . Since /// is [JsonIgnore]d and so invisible to the default serialized key, return null (never share) in that - /// case — mirroring LocalStorageProviderOptions. The Layer-1 (no-factory) key is unchanged. + /// case — mirroring WritableStoreProviderOptions. The Layer-1 (no-factory) key is unchanged. /// public string? GenerateProviderKey() => ClientFactory is not null diff --git a/src/Cocoar.Configuration.slnx b/src/Cocoar.Configuration.slnx index dd4d09a..2a22ef8 100644 --- a/src/Cocoar.Configuration.slnx +++ b/src/Cocoar.Configuration.slnx @@ -24,7 +24,7 @@ - + diff --git a/src/Cocoar.Configuration/Core/ConfigManager.cs b/src/Cocoar.Configuration/Core/ConfigManager.cs index 7423781..ecbfe2b 100644 --- a/src/Cocoar.Configuration/Core/ConfigManager.cs +++ b/src/Cocoar.Configuration/Core/ConfigManager.cs @@ -17,7 +17,7 @@ namespace Cocoar.Configuration.Core; -public sealed class ConfigManager : IConfigurationAccessor, ITenantConfigurationAccessor, ILocalStorageHost, IDisposable, IAsyncDisposable +public sealed class ConfigManager : IConfigurationAccessor, ITenantConfigurationAccessor, IWritableStoreHost, IDisposable, IAsyncDisposable { private List _setupDefinitions = null!; private readonly ConfigManagerCapabilityScope _capabilityScope; @@ -267,7 +267,7 @@ public bool TryGetConfig(out T? value) where T : class /// /// Computes the merged "base" JSON for from all rule layers BELOW the /// overlay layer identified by — i.e. the value the type would have - /// without that overlay. Used by LocalStorage to align override key casing against lower layers and to + /// without that overlay. Used by WritableStore to align override key casing against lower layers and to /// report base-vs-effective provenance. /// /// Thread-safety: this reads each manager's LastJsonContribution without taking the recompute @@ -305,11 +305,11 @@ internal MutableJsonObject BuildBaseJson(Type configType, Func isExcludedLayer) + // IWritableStoreHost — lets the WritableStore adapter compute base/effective JSON against the global pipeline. + MutableJsonObject IWritableStoreHost.BuildBaseJson(Type configType, Func isExcludedLayer) => BuildBaseJson(configType, isExcludedLayer); - JsonElement? ILocalStorageHost.GetConfigAsJson(Type type) => GetConfigAsJson(type); + JsonElement? IWritableStoreHost.GetConfigAsJson(Type type) => GetConfigAsJson(type); /// /// Gets a reactive wrapper for the specified configuration type. @@ -543,7 +543,7 @@ private TenantPipeline GetInitializedTenantOrThrow(string tenantId) } /// - /// The initialized tenant pipeline, for in-assembly facades (e.g. per-tenant LocalStorage) that need the + /// The initialized tenant pipeline, for in-assembly facades (e.g. per-tenant WritableStore) that need the /// tenant's own rule managers/host. Throws if the tenant is not initialized. /// internal TenantPipeline GetInitializedTenantPipeline(string tenantId) => GetInitializedTenantOrThrow(tenantId); diff --git a/src/Cocoar.Configuration/Core/ILocalStorageHost.cs b/src/Cocoar.Configuration/Core/IWritableStoreHost.cs similarity index 79% rename from src/Cocoar.Configuration/Core/ILocalStorageHost.cs rename to src/Cocoar.Configuration/Core/IWritableStoreHost.cs index 7cc7ea1..1a17044 100644 --- a/src/Cocoar.Configuration/Core/ILocalStorageHost.cs +++ b/src/Cocoar.Configuration/Core/IWritableStoreHost.cs @@ -5,12 +5,12 @@ namespace Cocoar.Configuration.Core; /// -/// The pipeline context a LocalStorage overlay adapter needs: the merged "base" JSON below the overlay layer +/// The pipeline context a WritableStore overlay adapter needs: the merged "base" JSON below the overlay layer /// (for sparse-write key alignment and provenance) and the effective snapshot JSON. Implemented by /// (the global pipeline) and TenantPipeline (a tenant), so the one adapter /// serves both global and per-tenant overlays (ADR-005 §7). /// -internal interface ILocalStorageHost +internal interface IWritableStoreHost { MutableJsonObject BuildBaseJson(Type configType, Func isExcludedLayer); diff --git a/src/Cocoar.Configuration/Core/TenantPipeline.cs b/src/Cocoar.Configuration/Core/TenantPipeline.cs index 92dd503..67696b8 100644 --- a/src/Cocoar.Configuration/Core/TenantPipeline.cs +++ b/src/Cocoar.Configuration/Core/TenantPipeline.cs @@ -26,7 +26,7 @@ namespace Cocoar.Configuration.Core; /// across all pipelines (ADR-005 §2). Each pipeline owns its own state/engine/accessor/reactive/rules. /// /// -internal sealed class TenantPipeline : ILocalStorageHost, IDisposable, IAsyncDisposable +internal sealed class TenantPipeline : IWritableStoreHost, IDisposable, IAsyncDisposable { private readonly ConfigManagerCapabilityScope _capabilityScope; // shared (read-only after Initialize) private readonly ExposureRegistry _bindingRegistry; // shared (frozen) @@ -107,7 +107,7 @@ await Engine.InitializeAndComputeAsync( Volatile.Write(ref _initialized, 1); } - // ILocalStorageHost — a per-tenant LocalStorage overlay computes its base/effective JSON against THIS + // IWritableStoreHost — a per-tenant WritableStore overlay computes its base/effective JSON against THIS // pipeline's own rule managers and snapshot (mirrors ConfigManager.BuildBaseJson over the global managers). public MutableJsonObject BuildBaseJson(Type configType, Func isExcludedLayer) { diff --git a/src/Cocoar.Configuration/Fluent/RuleBuilderBase.cs b/src/Cocoar.Configuration/Fluent/RuleBuilderBase.cs index 10ff847..6addb1b 100644 --- a/src/Cocoar.Configuration/Fluent/RuleBuilderBase.cs +++ b/src/Cocoar.Configuration/Fluent/RuleBuilderBase.cs @@ -72,7 +72,7 @@ public TBuilder TenantScoped() /// /// Attaches a system-level activation gate (composed with AND), evaluated independently of - /// so a later user .When() cannot clobber it. The service-backed overloads (FromStorage, + /// so a later user .When() cannot clobber it. The service-backed overloads (FromStore, /// FromHttp((sp,a)=>…)) — and third-party ones — use it to keep a Layer-2 rule dormant until the /// container is built: .WithActivationGate(_ => context.IsActive) (ADR-006). /// diff --git a/src/Cocoar.Configuration/Fluent/ServiceBackedProviderBuilder.cs b/src/Cocoar.Configuration/Fluent/ServiceBackedProviderBuilder.cs index 97d3f38..4c37619 100644 --- a/src/Cocoar.Configuration/Fluent/ServiceBackedProviderBuilder.cs +++ b/src/Cocoar.Configuration/Fluent/ServiceBackedProviderBuilder.cs @@ -12,7 +12,7 @@ namespace Cocoar.Configuration.Fluent; /// (so a non-DI rule like FromFile can still be placed in Layer 2) /// and additionally carries the that (sp, a) => … overloads read. /// -/// Because those overloads (FromStorage, FromHttp((sp,a)=>…), and any third-party one) target +/// Because those overloads (FromStore, FromHttp((sp,a)=>…), and any third-party one) target /// this type, using them inside the Layer-1 UseConfiguration — which yields a plain /// — is a compile error, not a runtime fault. A third-party provider /// is made service-backable simply by authoring an extension method on this type (and giving its provider options @@ -81,7 +81,7 @@ public ProviderRuleBuilder ServiceBackedConfigure<TDep>/IConfigureOptions<T>. /// /// Synchronous / in-memory by nature (it snapshots once per recompute, no change detection). For I/O-bound - /// sources (DB, HTTP) use an async provider instead — FromStorage, FromHttp((sp,a)=>…), or a + /// sources (DB, HTTP) use an async provider instead — FromStore, FromHttp((sp,a)=>…), or a /// custom provider — rather than blocking here. /// /// diff --git a/src/Cocoar.Configuration/Providers/Abstractions/IProviderServiceRegistration.cs b/src/Cocoar.Configuration/Providers/Abstractions/IProviderServiceRegistration.cs index 6d573bf..88f158c 100644 --- a/src/Cocoar.Configuration/Providers/Abstractions/IProviderServiceRegistration.cs +++ b/src/Cocoar.Configuration/Providers/Abstractions/IProviderServiceRegistration.cs @@ -33,7 +33,7 @@ private ProviderServiceRegistration(Type serviceType, object? instance, FuncThe service type to register (e.g. ILocalStorage<AppSettings>). + /// The service type to register (e.g. IWritableStore<AppSettings>). public Type ServiceType { get; } /// The pre-built singleton instance, or when a is used. diff --git a/src/Cocoar.Configuration/Providers/LocalStorageProvider/LocalStorageProviderQueryOptions.cs b/src/Cocoar.Configuration/Providers/LocalStorageProvider/LocalStorageProviderQueryOptions.cs deleted file mode 100644 index 1fc5160..0000000 --- a/src/Cocoar.Configuration/Providers/LocalStorageProvider/LocalStorageProviderQueryOptions.cs +++ /dev/null @@ -1,8 +0,0 @@ -using Cocoar.Configuration.Providers.Abstractions; - -namespace Cocoar.Configuration.Providers; - -public sealed class LocalStorageProviderQueryOptions : IProviderQuery -{ - public static readonly LocalStorageProviderQueryOptions Default = new(); -} diff --git a/src/Cocoar.Configuration/Providers/LocalStorageProvider/FileStorageBackend.cs b/src/Cocoar.Configuration/Providers/WritableStoreProvider/FileStoreBackend.cs similarity index 90% rename from src/Cocoar.Configuration/Providers/LocalStorageProvider/FileStorageBackend.cs rename to src/Cocoar.Configuration/Providers/WritableStoreProvider/FileStoreBackend.cs index 9affa3f..73e9445 100644 --- a/src/Cocoar.Configuration/Providers/LocalStorageProvider/FileStorageBackend.cs +++ b/src/Cocoar.Configuration/Providers/WritableStoreProvider/FileStoreBackend.cs @@ -2,16 +2,16 @@ namespace Cocoar.Configuration.Providers; /// /// File-based storage backend using atomic write-temp-then-rename pattern. -/// Default directory: {AppContext.BaseDirectory}/.cocoar/localStorage/ +/// Default directory: {AppContext.BaseDirectory}/.cocoar/store/ /// -public sealed class FileStorageBackend : IStorageBackend +public sealed class FileStoreBackend : IStoreBackend { private readonly string _directory; - public FileStorageBackend(string? directory = null) + public FileStoreBackend(string? directory = null) { _directory = directory - ?? Path.Combine(AppContext.BaseDirectory, ".cocoar", "localStorage"); + ?? Path.Combine(AppContext.BaseDirectory, ".cocoar", "store"); } public async Task ReadAsync(string key, CancellationToken ct = default) diff --git a/src/Cocoar.Configuration/Providers/LocalStorageProvider/IStorageBackend.cs b/src/Cocoar.Configuration/Providers/WritableStoreProvider/IStoreBackend.cs similarity index 84% rename from src/Cocoar.Configuration/Providers/LocalStorageProvider/IStorageBackend.cs rename to src/Cocoar.Configuration/Providers/WritableStoreProvider/IStoreBackend.cs index 278f5e8..83384a2 100644 --- a/src/Cocoar.Configuration/Providers/LocalStorageProvider/IStorageBackend.cs +++ b/src/Cocoar.Configuration/Providers/WritableStoreProvider/IStoreBackend.cs @@ -1,10 +1,10 @@ namespace Cocoar.Configuration.Providers; /// -/// Abstraction for the persistence layer used by LocalStorageProvider. +/// Abstraction for the persistence layer used by WritableStoreProvider. /// Default implementation is file-based; can be replaced with SQLite, Marten, etc. /// -public interface IStorageBackend +public interface IStoreBackend { /// /// Reads raw UTF-8 JSON bytes for the given key. diff --git a/src/Cocoar.Configuration/Providers/LocalStorageProvider/OverlayPathResolver.cs b/src/Cocoar.Configuration/Providers/WritableStoreProvider/OverlayPathResolver.cs similarity index 99% rename from src/Cocoar.Configuration/Providers/LocalStorageProvider/OverlayPathResolver.cs rename to src/Cocoar.Configuration/Providers/WritableStoreProvider/OverlayPathResolver.cs index f00dfee..add145c 100644 --- a/src/Cocoar.Configuration/Providers/LocalStorageProvider/OverlayPathResolver.cs +++ b/src/Cocoar.Configuration/Providers/WritableStoreProvider/OverlayPathResolver.cs @@ -47,7 +47,7 @@ internal static string ResolveKeyPath(Expression> sel if (!allowSecretMembers && IsSecretType(memberType)) { throw new NotSupportedException( - $"Member '{member.Name}' is a secret and cannot be set as plaintext via LocalStorage. " + + $"Member '{member.Name}' is a secret and cannot be set as plaintext via WritableStore. " + "Use SetSecretAsync with a pre-encrypted envelope, or manage secrets via the Secrets CLI/provider."); } diff --git a/src/Cocoar.Configuration/Providers/LocalStorageProvider/OverlaySerialization.cs b/src/Cocoar.Configuration/Providers/WritableStoreProvider/OverlaySerialization.cs similarity index 96% rename from src/Cocoar.Configuration/Providers/LocalStorageProvider/OverlaySerialization.cs rename to src/Cocoar.Configuration/Providers/WritableStoreProvider/OverlaySerialization.cs index 5db80b9..36f3478 100644 --- a/src/Cocoar.Configuration/Providers/LocalStorageProvider/OverlaySerialization.cs +++ b/src/Cocoar.Configuration/Providers/WritableStoreProvider/OverlaySerialization.cs @@ -5,7 +5,7 @@ namespace Cocoar.Configuration.Providers; /// -/// JSON options for reading and writing LocalStorage overlay values. +/// JSON options for reading and writing WritableStore overlay values. /// /// These are deliberately vanilla: the configuration pipeline's options carry custom converters /// (notably StringToPrimitiveConverter<T>, whose Write re-enters with the same options and diff --git a/src/Cocoar.Configuration/Providers/LocalStorageProvider/SparseOverlayMutator.cs b/src/Cocoar.Configuration/Providers/WritableStoreProvider/SparseOverlayMutator.cs similarity index 100% rename from src/Cocoar.Configuration/Providers/LocalStorageProvider/SparseOverlayMutator.cs rename to src/Cocoar.Configuration/Providers/WritableStoreProvider/SparseOverlayMutator.cs diff --git a/src/Cocoar.Configuration/Providers/LocalStorageProvider/TenantLocalStorageExtensions.cs b/src/Cocoar.Configuration/Providers/WritableStoreProvider/TenantWritableStoreExtensions.cs similarity index 51% rename from src/Cocoar.Configuration/Providers/LocalStorageProvider/TenantLocalStorageExtensions.cs rename to src/Cocoar.Configuration/Providers/WritableStoreProvider/TenantWritableStoreExtensions.cs index bd4f727..d9559e5 100644 --- a/src/Cocoar.Configuration/Providers/LocalStorageProvider/TenantLocalStorageExtensions.cs +++ b/src/Cocoar.Configuration/Providers/WritableStoreProvider/TenantWritableStoreExtensions.cs @@ -1,26 +1,26 @@ using Cocoar.Configuration.Core; -using Cocoar.Configuration.LocalStorage; +using Cocoar.Configuration.WritableStore; namespace Cocoar.Configuration.Providers; /// -/// Per-tenant LocalStorage write facade (ADR-005 §7). The global write facade is ILocalStorage<T> +/// Per-tenant WritableStore write facade (ADR-005 §7). The global write facade is IWritableStore<T> /// resolved from DI; the per-tenant facade is obtained explicitly with the tenant id, exactly like the rest of /// the tenant surface (GetConfigForTenant, GetFeatureFlagsForTenant, …). /// -public static class TenantLocalStorageExtensions +public static class TenantWritableStoreExtensions { /// - /// Returns the LocalStorage write facade for a tenant's overlay of . Writes target + /// Returns the WritableStore write facade for a tenant's overlay of . Writes target /// the tenant pipeline's own store and trigger only that tenant's recompute. For per-tenant isolation the /// rule must use a per-tenant backend, e.g. - /// rules.For<T>().FromLocalStorage(a => BackendFor(a.Tenant)).TenantScoped() — the factory overload + /// rules.For<T>().FromStore(a => BackendFor(a.Tenant)).TenantScoped() — the factory overload /// keys its store by accessor.Tenant, so each tenant gets its own store/backend. /// /// - /// The tenant is not initialized, or it has no LocalStorage rule for . + /// The tenant is not initialized, or it has no WritableStore rule for . /// - public static ILocalStorage GetLocalStorageForTenant(this ConfigManager manager, string tenantId) + public static IWritableStore GetWritableStoreForTenant(this ConfigManager manager, string tenantId) where T : class { ArgumentNullException.ThrowIfNull(manager); @@ -28,11 +28,11 @@ public static ILocalStorage GetLocalStorageForTenant(this ConfigManager ma var pipeline = manager.GetInitializedTenantPipeline(tenantId); - // The tenant pipeline's store for T (last LocalStorage rule wins — highest-precedence overlay). - LocalStorageStore? store = null; + // The tenant pipeline's store for T (last WritableStore rule wins — highest-precedence overlay). + WritableStoreState? store = null; foreach (var ruleManager in pipeline.RuleManagers) { - if (ruleManager.CurrentProvider is LocalStorageProvider provider + if (ruleManager.CurrentProvider is WritableStoreProvider provider && provider.Store.ConfigurationType == typeof(T)) { store = provider.Store; @@ -42,12 +42,12 @@ public static ILocalStorage GetLocalStorageForTenant(this ConfigManager ma if (store is null) { throw new InvalidOperationException( - $"No LocalStorage rule is registered for '{typeof(T).Name}' in tenant '{tenantId}'. " + - $"Add rules.For<{typeof(T).Name}>().FromLocalStorage(...).TenantScoped()."); + $"No WritableStore rule is registered for '{typeof(T).Name}' in tenant '{tenantId}'. " + + $"Add rules.For<{typeof(T).Name}>().FromStore(...).TenantScoped()."); } - // The TenantPipeline is the ILocalStorageHost: base/effective JSON is computed over the tenant's own + // The TenantPipeline is the IWritableStoreHost: base/effective JSON is computed over the tenant's own // rule managers and snapshot, so provenance and sparse-write key alignment are per tenant. - return new LocalStorageAdapter(pipeline, store); + return new WritableStoreAdapter(pipeline, store); } } diff --git a/src/Cocoar.Configuration/Providers/LocalStorageProvider/LocalStorageAdapter.cs b/src/Cocoar.Configuration/Providers/WritableStoreProvider/WritableStoreAdapter.cs similarity index 89% rename from src/Cocoar.Configuration/Providers/LocalStorageProvider/LocalStorageAdapter.cs rename to src/Cocoar.Configuration/Providers/WritableStoreProvider/WritableStoreAdapter.cs index 604df84..19e1062 100644 --- a/src/Cocoar.Configuration/Providers/LocalStorageProvider/LocalStorageAdapter.cs +++ b/src/Cocoar.Configuration/Providers/WritableStoreProvider/WritableStoreAdapter.cs @@ -2,7 +2,7 @@ using System.Text.Json; using System.Text.Json.Nodes; using Cocoar.Configuration.Core; -using Cocoar.Configuration.LocalStorage; +using Cocoar.Configuration.WritableStore; using Cocoar.Configuration.Rules; using Cocoar.Configuration.Secrets.Core; using Cocoar.Configuration.Secrets.SecretTypes; @@ -11,27 +11,27 @@ namespace Cocoar.Configuration.Providers; /// -/// Implements both the type-safe facade and the raw -/// surface over a single . +/// Implements both the type-safe facade and the raw +/// surface over a single . /// All writes are sparse (only the touched leaf is persisted) and go through the store's atomic /// read-transform-write lock; provenance is computed from the base layers, the merged effective value, /// and the persisted overlay. /// -internal sealed class LocalStorageAdapter : ILocalStorage, ILocalStorageOverlay, IDisposable +internal sealed class WritableStoreAdapter : IWritableStore, IWritableStoreOverlay, IDisposable where T : class { - private readonly ILocalStorageHost _host; - private readonly LocalStorageStore _store; + private readonly IWritableStoreHost _host; + private readonly WritableStoreState _store; // _host is the pipeline this overlay belongs to: the global ConfigManager, or a TenantPipeline for a // per-tenant overlay (ADR-005 §7). Both supply base/effective JSON over their OWN rule managers/snapshot. - public LocalStorageAdapter(ILocalStorageHost host, LocalStorageStore store) + public WritableStoreAdapter(IWritableStoreHost host, WritableStoreState store) { _host = host ?? throw new ArgumentNullException(nameof(host)); _store = store ?? throw new ArgumentNullException(nameof(store)); } - public ILocalStorageOverlay Overlay => this; + public IWritableStoreOverlay Overlay => this; // ---------------------------------------------------------------- typed facade @@ -76,7 +76,7 @@ public Task SetSecretAsync(Expression>> select return JsonSerializer.Deserialize(bytes, OverlaySerialization.ReadOptions); } - public async Task> DescribeAsync(CancellationToken ct = default) + public async Task> DescribeAsync(CancellationToken ct = default) { var baseElement = ToJsonElement(_host.BuildBaseJson(typeof(T), IsThisLayer)); var effective = _host.GetConfigAsJson(typeof(T)); @@ -96,14 +96,14 @@ public async Task> DescribeAsync(CancellationToken } allPaths.UnionWith(overriddenPaths); - var entries = new List(allPaths.Count); + var entries = new List(allPaths.Count); foreach (var path in allPaths) { JsonElement? baseValue = TrySelect(baseElement, path, out var bv) ? bv : null; JsonElement? effectiveValue = effective is { } e && TrySelect(e, path, out var ev) ? ev : null; - entries.Add(new OverrideEntry(path, baseValue, effectiveValue, overriddenPaths.Contains(path))); + entries.Add(new StoreEntry(path, baseValue, effectiveValue, overriddenPaths.Contains(path))); } return entries; @@ -131,7 +131,7 @@ public async Task SetSecretEnvelopeAsync(string keyPath, JsonNode envelope, Canc { throw new ArgumentException( "Value is not a well-formed encrypted secret envelope (expected an object with " + - "type=\"cocoar.secret\" and version=1). LocalStorage only accepts pre-encrypted secret envelopes.", + "type=\"cocoar.secret\" and version=1). WritableStore only accepts pre-encrypted secret envelopes.", nameof(envelope)); } @@ -172,7 +172,7 @@ public Task ClearAsync(CancellationToken ct = default) // ---------------------------------------------------------------- helpers private bool IsThisLayer(IRuleManager manager) - => manager.CurrentProvider is LocalStorageProvider provider && ReferenceEquals(provider.Store, _store); + => manager.CurrentProvider is WritableStoreProvider provider && ReferenceEquals(provider.Store, _store); private static void ValidateKeyPath(string keyPath) { diff --git a/src/Cocoar.Configuration/Providers/LocalStorageProvider/LocalStorageProvider.cs b/src/Cocoar.Configuration/Providers/WritableStoreProvider/WritableStoreProvider.cs similarity index 52% rename from src/Cocoar.Configuration/Providers/LocalStorageProvider/LocalStorageProvider.cs rename to src/Cocoar.Configuration/Providers/WritableStoreProvider/WritableStoreProvider.cs index ad2d75e..253c2b1 100644 --- a/src/Cocoar.Configuration/Providers/LocalStorageProvider/LocalStorageProvider.cs +++ b/src/Cocoar.Configuration/Providers/WritableStoreProvider/WritableStoreProvider.cs @@ -3,26 +3,26 @@ namespace Cocoar.Configuration.Providers; /// -/// Provider that reads from a . -/// The store is shared state owned by the closure in FromLocalStorage() — +/// Provider that reads from a . +/// The store is shared state owned by the closure in FromStore() — /// the provider does NOT own or dispose it. /// -public sealed class LocalStorageProvider(LocalStorageProviderOptions options) - : ConfigurationProvider(options) +public sealed class WritableStoreProvider(WritableStoreProviderOptions options) + : ConfigurationProvider(options) { /// /// The store backing this provider. Exposed so the overlay layer can be located in the rule /// pipeline by store reference (used for base/prefix computation and provenance). /// - internal LocalStorageStore Store => ProviderOptions.Store; + internal WritableStoreState Store => ProviderOptions.Store; public override async Task FetchConfigurationBytesAsync( - LocalStorageProviderQueryOptions query, CancellationToken ct = default) + WritableStoreProviderQueryOptions query, CancellationToken ct = default) { return await ProviderOptions.Store.ReadBytesAsync(ct).ConfigureAwait(false); } - public override IObservable ChangesAsBytes(LocalStorageProviderQueryOptions query) + public override IObservable ChangesAsBytes(WritableStoreProviderQueryOptions query) { return ProviderOptions.Store.Changes; } diff --git a/src/Cocoar.Configuration/Providers/LocalStorageProvider/LocalStorageProviderOptions.cs b/src/Cocoar.Configuration/Providers/WritableStoreProvider/WritableStoreProviderOptions.cs similarity index 60% rename from src/Cocoar.Configuration/Providers/LocalStorageProvider/LocalStorageProviderOptions.cs rename to src/Cocoar.Configuration/Providers/WritableStoreProvider/WritableStoreProviderOptions.cs index 22be37c..958cbaa 100644 --- a/src/Cocoar.Configuration/Providers/LocalStorageProvider/LocalStorageProviderOptions.cs +++ b/src/Cocoar.Configuration/Providers/WritableStoreProvider/WritableStoreProviderOptions.cs @@ -1,37 +1,37 @@ using Cocoar.Configuration.Core; -using Cocoar.Configuration.LocalStorage; +using Cocoar.Configuration.WritableStore; using Cocoar.Configuration.Providers.Abstractions; namespace Cocoar.Configuration.Providers; -public sealed class LocalStorageProviderOptions(LocalStorageStore store) +public sealed class WritableStoreProviderOptions(WritableStoreState store) : IProviderConfiguration, IProviderServiceRegistration { - public LocalStorageStore Store { get; } = store ?? throw new ArgumentNullException(nameof(store)); + public WritableStoreState Store { get; } = store ?? throw new ArgumentNullException(nameof(store)); /// - /// Returns null to indicate non-reusable. Each LocalStorage rule gets its own provider instance - /// because each is backed by a unique tied to a specific configuration type. + /// Returns null to indicate non-reusable. Each WritableStore rule gets its own provider instance + /// because each is backed by a unique tied to a specific configuration type. /// public string? GenerateProviderKey() => null; /// /// Registers the overlay adapter as a singleton built at resolve time (so it can pull /// for base/prefix computation), then resolves both - /// and to that one instance. + /// and to that one instance. /// public IEnumerable GetServiceRegistrations(Type concreteType) { - var adapterType = typeof(LocalStorageAdapter<>).MakeGenericType(concreteType); - var storageInterface = typeof(ILocalStorage<>).MakeGenericType(concreteType); - var overlayInterface = typeof(ILocalStorageOverlay<>).MakeGenericType(concreteType); + var adapterType = typeof(WritableStoreAdapter<>).MakeGenericType(concreteType); + var storageInterface = typeof(IWritableStore<>).MakeGenericType(concreteType); + var overlayInterface = typeof(IWritableStoreOverlay<>).MakeGenericType(concreteType); var store = Store; yield return ProviderServiceRegistration.Singleton(adapterType, sp => { var configManager = (ConfigManager?)sp.GetService(typeof(ConfigManager)) ?? throw new InvalidOperationException( - "ConfigManager is not registered. Call AddCocoarConfiguration before resolving ILocalStorage."); + "ConfigManager is not registered. Call AddCocoarConfiguration before resolving IWritableStore."); return Activator.CreateInstance(adapterType, configManager, store)!; }); diff --git a/src/Cocoar.Configuration/Providers/WritableStoreProvider/WritableStoreProviderQueryOptions.cs b/src/Cocoar.Configuration/Providers/WritableStoreProvider/WritableStoreProviderQueryOptions.cs new file mode 100644 index 0000000..6fad50e --- /dev/null +++ b/src/Cocoar.Configuration/Providers/WritableStoreProvider/WritableStoreProviderQueryOptions.cs @@ -0,0 +1,8 @@ +using Cocoar.Configuration.Providers.Abstractions; + +namespace Cocoar.Configuration.Providers; + +public sealed class WritableStoreProviderQueryOptions : IProviderQuery +{ + public static readonly WritableStoreProviderQueryOptions Default = new(); +} diff --git a/src/Cocoar.Configuration/Providers/LocalStorageProvider/LocalStorageRulesExtensions.cs b/src/Cocoar.Configuration/Providers/WritableStoreProvider/WritableStoreRulesExtensions.cs similarity index 69% rename from src/Cocoar.Configuration/Providers/LocalStorageProvider/LocalStorageRulesExtensions.cs rename to src/Cocoar.Configuration/Providers/WritableStoreProvider/WritableStoreRulesExtensions.cs index b2195eb..e1531e8 100644 --- a/src/Cocoar.Configuration/Providers/LocalStorageProvider/LocalStorageRulesExtensions.cs +++ b/src/Cocoar.Configuration/Providers/WritableStoreProvider/WritableStoreRulesExtensions.cs @@ -4,16 +4,16 @@ namespace Cocoar.Configuration.Providers; -public static class LocalStorageRulesExtensions +public static class WritableStoreRulesExtensions { /// /// Creates a local-storage-backed configuration rule. /// Reads from and writes to persistent storage. By default uses file-based storage - /// at {AppContext.BaseDirectory}/.cocoar/localStorage/. + /// at {AppContext.BaseDirectory}/.cocoar/store/. /// /// /// - /// Use (via DI) to write configuration at runtime. + /// Use (via DI) to write configuration at runtime. /// Writes trigger a recompute of the configuration pipeline. /// /// @@ -21,21 +21,21 @@ public static class LocalStorageRulesExtensions /// /// /// The typed provider builder. - /// Optional custom storage backend. Defaults to . - public static ProviderRuleBuilder - FromLocalStorage(this TypedProviderBuilder builder, IStorageBackend? backend = null) + /// Optional custom storage backend. Defaults to . + public static ProviderRuleBuilder + FromStore(this TypedProviderBuilder builder, IStoreBackend? backend = null) where T : class { - var effectiveBackend = backend ?? new FileStorageBackend(); + var effectiveBackend = backend ?? new FileStoreBackend(); var storageKey = typeof(T).FullName ?? typeof(T).Name; - var store = new LocalStorageStore(effectiveBackend, storageKey) + var store = new WritableStoreState(effectiveBackend, storageKey) { ConfigurationType = typeof(T) }; return new( - _ => new LocalStorageProviderOptions(store), - _ => LocalStorageProviderQueryOptions.Default, + _ => new WritableStoreProviderOptions(store), + _ => WritableStoreProviderQueryOptions.Default, typeof(T) ); } @@ -53,9 +53,9 @@ public static ProviderRuleBuilder /// The typed provider builder. /// A factory that receives the current - /// and the current (null on first call), and returns the backend to use. - public static ProviderRuleBuilder - FromLocalStorage(this TypedProviderBuilder builder, Func backendFactory) + /// and the current (null on first call), and returns the backend to use. + public static ProviderRuleBuilder + FromStore(this TypedProviderBuilder builder, Func backendFactory) where T : class { ArgumentNullException.ThrowIfNull(backendFactory); @@ -64,7 +64,7 @@ public static ProviderRuleBuilder(); + var stores = new ConcurrentDictionary(); var storageKey = typeof(T).FullName ?? typeof(T).Name; return new( @@ -76,7 +76,7 @@ public static ProviderRuleBuilder LocalStorageProviderQueryOptions.Default, + _ => WritableStoreProviderQueryOptions.Default, typeof(T) ); } diff --git a/src/Cocoar.Configuration/Providers/LocalStorageProvider/LocalStorageStore.cs b/src/Cocoar.Configuration/Providers/WritableStoreProvider/WritableStoreState.cs similarity index 85% rename from src/Cocoar.Configuration/Providers/LocalStorageProvider/LocalStorageStore.cs rename to src/Cocoar.Configuration/Providers/WritableStoreProvider/WritableStoreState.cs index c5c4ea4..ef6053f 100644 --- a/src/Cocoar.Configuration/Providers/LocalStorageProvider/LocalStorageStore.cs +++ b/src/Cocoar.Configuration/Providers/WritableStoreProvider/WritableStoreState.cs @@ -3,17 +3,17 @@ namespace Cocoar.Configuration.Providers; /// -/// Shared state object that bridges the provider (read path) and ILocalStorage<T> (write path). -/// Created once in FromLocalStorage() and captured by both the provider options closure and DI registration. +/// Shared state object that bridges the provider (read path) and IWritableStore<T> (write path). +/// Created once in FromStore() and captured by both the provider options closure and DI registration. /// -public sealed class LocalStorageStore : IDisposable +public sealed class WritableStoreState : IDisposable { - private volatile IStorageBackend _backend; + private volatile IStoreBackend _backend; private readonly string _storageKey; private readonly SimpleSubject _changeSubject = new(); private readonly SemaphoreSlim _writeLock = new(1, 1); - public LocalStorageStore(IStorageBackend backend, string storageKey) + public WritableStoreState(IStoreBackend backend, string storageKey) { ArgumentNullException.ThrowIfNull(backend); ArgumentException.ThrowIfNullOrWhiteSpace(storageKey); @@ -26,14 +26,14 @@ public LocalStorageStore(IStorageBackend backend, string storageKey) /// The current storage backend. Exposed so the config-aware factory can /// pass it back to the user for comparison/reuse decisions. /// - internal IStorageBackend Backend => _backend; + internal IStoreBackend Backend => _backend; /// /// Replaces the storage backend. Called during recompute when config-aware /// factory produces a new backend (e.g., connection string changed). /// The store instance stays the same — DI references remain valid. /// - internal void ReplaceBackend(IStorageBackend backend) + internal void ReplaceBackend(IStoreBackend backend) { ArgumentNullException.ThrowIfNull(backend); _backend = backend; @@ -41,7 +41,7 @@ internal void ReplaceBackend(IStorageBackend backend) /// /// The configuration type this store is associated with. - /// Used by DI registration to match ILocalStorage<T> to the correct store. + /// Used by DI registration to match IWritableStore<T> to the correct store. /// public Type ConfigurationType { get; init; } = null!; diff --git a/src/Cocoar.Configuration/Rules/IRuleManager.cs b/src/Cocoar.Configuration/Rules/IRuleManager.cs index 85b00a8..d29ef20 100644 --- a/src/Cocoar.Configuration/Rules/IRuleManager.cs +++ b/src/Cocoar.Configuration/Rules/IRuleManager.cs @@ -33,7 +33,7 @@ internal interface IRuleManager : IDisposable /// /// The provider instance currently held by this rule, or if not yet acquired - /// (or for aggregates, which have no single provider). Used by overlay providers (e.g. LocalStorage) + /// (or for aggregates, which have no single provider). Used by overlay providers (e.g. WritableStore) /// to locate their own layer in the pipeline by provider/store reference. /// ConfigurationProvider? CurrentProvider { get; } diff --git a/src/Examples/ServiceBackedConfig/Program.cs b/src/Examples/ServiceBackedConfig/Program.cs index 9e17094..34a801b 100644 --- a/src/Examples/ServiceBackedConfig/Program.cs +++ b/src/Examples/ServiceBackedConfig/Program.cs @@ -9,7 +9,7 @@ // ADR-006 "service-backed" (Layer-2) configuration, end to end. // // Layer 1 (UseConfiguration) is eager and DI-free: a bootstrap default available before the container exists. -// Layer 2 (UseServiceBackedConfiguration) is lazy and container-owned: its FromStorage factory resolves a +// Layer 2 (UseServiceBackedConfiguration) is lazy and container-owned: its FromStore factory resolves a // DI-managed "store" (here an in-memory stand-in for Marten/EF) and overrides the base. Layer 2 activates on // host start via a recompute — so a reactive view obtained BEFORE the host runs still receives the upgrade. @@ -27,7 +27,7 @@ // Layer 2 — container-owned: the factory receives the IServiceProvider and resolves the store. .UseServiceBackedConfiguration(rules => [ - rules.For().FromStorage((sp, _) => sp.GetRequiredService().Backend), + rules.For().FromStore((sp, _) => sp.GetRequiredService().Backend), ]) .UseDebounce(25)); @@ -65,16 +65,16 @@ public sealed record FeatureConfig /// A DI-managed store; in a real app this wraps Marten's IDocumentStore or an EF IDbContextFactory. public interface IFeatureStore { - IStorageBackend Backend { get; } + IStoreBackend Backend { get; } } internal sealed class InMemoryFeatureStore : IFeatureStore { // The "row" the store would load — overrides Banner, inherits MaxItems from the Layer-1 base (sparse overlay). - public IStorageBackend Backend { get; } = new SeededBackend("""{ "Banner": "live from the database" }"""); + public IStoreBackend Backend { get; } = new SeededBackend("""{ "Banner": "live from the database" }"""); } -internal sealed class SeededBackend(string json) : IStorageBackend +internal sealed class SeededBackend(string json) : IStoreBackend { private byte[] _data = Encoding.UTF8.GetBytes(json); diff --git a/src/Examples/LocalStorageOverride/Program.cs b/src/Examples/WritableStoreExample/Program.cs similarity index 92% rename from src/Examples/LocalStorageOverride/Program.cs rename to src/Examples/WritableStoreExample/Program.cs index 30bd0ed..d642815 100644 --- a/src/Examples/LocalStorageOverride/Program.cs +++ b/src/Examples/WritableStoreExample/Program.cs @@ -1,15 +1,15 @@ using System.Diagnostics; using Cocoar.Configuration.Core; using Cocoar.Configuration.DI; -using Cocoar.Configuration.LocalStorage; +using Cocoar.Configuration.WritableStore; using Cocoar.Configuration.Providers; using Microsoft.Extensions.DependencyInjection; -namespace Examples.LocalStorageOverride; +namespace Examples.WritableStoreExample; -// Demonstrates LocalStorage as a SPARSE OVERRIDE OVERLAY: +// Demonstrates WritableStore as a SPARSE OVERRIDE OVERLAY: // - lower layers (here, a static JSON layer) supply the DEFAULTS -// - the application overrides INDIVIDUAL values at runtime via ILocalStorage +// - the application overrides INDIVIDUAL values at runtime via IWritableStore // - only the overridden keys are persisted; everything else keeps inheriting // - reset removes an override so the value falls back to the default again public static class Program @@ -29,12 +29,12 @@ public static async Task Main() // Defaults supplied by the normal sources (a file/env/etc. — a static layer here): rules.For().FromStaticJson("""{ "Host": "smtp.default.com", "Port": 25, "UseSsl": false }"""), // The app-controlled override layer, placed AFTER so it wins for the keys it sets: - rules.For().FromLocalStorage(), + rules.For().FromStore(), ])); using var provider = services.BuildServiceProvider(); var manager = provider.GetRequiredService(); - var storage = provider.GetRequiredService>(); + var storage = provider.GetRequiredService>(); // Start from a clean overlay so the demo is deterministic across runs. await storage.ClearAsync(); @@ -59,7 +59,7 @@ public static async Task Main() { Console.WriteLine( $" {entry.KeyPath,-8} base={Render(entry.BaseValue),-20} " + - $"effective={Render(entry.EffectiveValue),-20} overridden={entry.IsOverridden}"); + $"effective={Render(entry.EffectiveValue),-20} overridden={entry.IsSet}"); } // Reset one override — the value falls back to the default. diff --git a/src/Examples/LocalStorageOverride/LocalStorageOverride.csproj b/src/Examples/WritableStoreExample/WritableStoreExample.csproj similarity index 88% rename from src/Examples/LocalStorageOverride/LocalStorageOverride.csproj rename to src/Examples/WritableStoreExample/WritableStoreExample.csproj index 39f07f2..2304e16 100644 --- a/src/Examples/LocalStorageOverride/LocalStorageOverride.csproj +++ b/src/Examples/WritableStoreExample/WritableStoreExample.csproj @@ -5,7 +5,7 @@ enable enable true - Examples.LocalStorageOverride + Examples.WritableStoreExample diff --git a/src/tests/Cocoar.Configuration.MultiTenant.Tests/TenantSecretsTests.cs b/src/tests/Cocoar.Configuration.MultiTenant.Tests/TenantSecretsTests.cs index 8ac405f..bec7d77 100644 --- a/src/tests/Cocoar.Configuration.MultiTenant.Tests/TenantSecretsTests.cs +++ b/src/tests/Cocoar.Configuration.MultiTenant.Tests/TenantSecretsTests.cs @@ -3,7 +3,7 @@ using System.Text.Json; using Cocoar.Configuration.Core; using Cocoar.Configuration.DI; -using Cocoar.Configuration.Providers; // FromStaticJson / FromLocalStorage / IStorageBackend / GetLocalStorageForTenant +using Cocoar.Configuration.Providers; // FromStaticJson / FromStore / IStoreBackend / GetWritableStoreForTenant using Cocoar.Configuration.Secrets; using Cocoar.Configuration.Secrets.SecretTypes; using Cocoar.Configuration.X509Encryption; @@ -36,7 +36,7 @@ public async Task PerTenantKid_EachTenantDecryptsItsOwnSecret() using var certB = GenerateTenantCert("tenantB"); var backends = new Dictionary(); - IStorageBackend BackendFor(string? tenant) + IStoreBackend BackendFor(string? tenant) { if (!backends.TryGetValue(tenant ?? "", out var b)) backends[tenant ?? ""] = b = new InMemoryBackend(); return b; @@ -47,7 +47,7 @@ IStorageBackend BackendFor(string? tenant) .UseConfiguration(rules => [ rules.For().FromStaticJson("{}"), - rules.For().FromLocalStorage((a, _) => BackendFor(a.Tenant)).TenantScoped(), + rules.For().FromStore((a, _) => BackendFor(a.Tenant)).TenantScoped(), ]) .UseSecretsSetup(secrets => secrets.UseCertificatesFromFolder(_certsRoot)) .UseDebounce(25)); @@ -60,14 +60,14 @@ IStorageBackend BackendFor(string? tenant) await tenants.InitializeTenantAsync("tenantB"); // Tenant A: encrypt "secret-A" to A's public key (kid=tenantA), write to A's overlay -> A decrypts. - await mgr.GetLocalStorageForTenant("tenantA") + await mgr.GetWritableStoreForTenant("tenantA") .SetSecretAsync(x => x.ApiKey!, EncryptForKid(certA.GetRSAPublicKey()!, "tenantA", "secret-A")); await TenantWait.UntilAsync(() => mgr.GetConfigForTenant("tenantA")?.ApiKey is not null, "tenant A secret applied"); using (var leaseA = mgr.GetConfigForTenant("tenantA")!.ApiKey!.Open()) Assert.Equal("secret-A", leaseA.Value); // Tenant B: its own kid + cert. - await mgr.GetLocalStorageForTenant("tenantB") + await mgr.GetWritableStoreForTenant("tenantB") .SetSecretAsync(x => x.ApiKey!, EncryptForKid(certB.GetRSAPublicKey()!, "tenantB", "secret-B")); await TenantWait.UntilAsync(() => mgr.GetConfigForTenant("tenantB")?.ApiKey is not null, "tenant B secret applied"); using (var leaseB = mgr.GetConfigForTenant("tenantB")!.ApiKey!.Open()) diff --git a/src/tests/Cocoar.Configuration.MultiTenant.Tests/TenantLocalStorageTests.cs b/src/tests/Cocoar.Configuration.MultiTenant.Tests/TenantWritableStoreTests.cs similarity index 73% rename from src/tests/Cocoar.Configuration.MultiTenant.Tests/TenantLocalStorageTests.cs rename to src/tests/Cocoar.Configuration.MultiTenant.Tests/TenantWritableStoreTests.cs index 2bf3589..fe10c8a 100644 --- a/src/tests/Cocoar.Configuration.MultiTenant.Tests/TenantLocalStorageTests.cs +++ b/src/tests/Cocoar.Configuration.MultiTenant.Tests/TenantWritableStoreTests.cs @@ -1,12 +1,12 @@ using System.Collections.Concurrent; using Cocoar.Configuration.Core; -using Cocoar.Configuration.LocalStorage; -using Cocoar.Configuration.Providers; // FromObservable / FromLocalStorage / IStorageBackend / GetLocalStorageForTenant +using Cocoar.Configuration.WritableStore; +using Cocoar.Configuration.Providers; // FromObservable / FromStore / IStoreBackend / GetWritableStoreForTenant namespace Cocoar.Configuration.MultiTenant.Tests; /// In-memory backend so each tenant gets an isolated, file-free overlay store. -internal sealed class InMemoryBackend : IStorageBackend +internal sealed class InMemoryBackend : IStoreBackend { private byte[]? _data; @@ -20,13 +20,13 @@ public Task WriteAsync(string key, byte[] data, CancellationToken ct = default) } /// -/// (P4) per-tenant LocalStorage through the real API: each tenant's overlay uses its own backend (the factory +/// (P4) per-tenant WritableStore through the real API: each tenant's overlay uses its own backend (the factory /// overload keys the store by accessor.Tenant), so a write to one tenant's overlay leaves the others untouched, -/// and provenance is computed per tenant. Ports the POC's TenantLocalStoragePocTests. +/// and provenance is computed per tenant. Ports the POC's TenantWritableStorePocTests. /// [Trait("Category", "MultiTenant")] [Trait("Type", "Unit")] -public class TenantLocalStorageTests +public class TenantWritableStoreTests { public sealed record Smtp { @@ -35,16 +35,16 @@ public sealed record Smtp } [Fact] - public async Task LocalStorageOverlay_IsPerTenant_WithDistinctBackends() + public async Task WritableStoreOverlay_IsPerTenant_WithDistinctBackends() { var backends = new ConcurrentDictionary(); - IStorageBackend BackendFor(string? tenant) => backends.GetOrAdd(tenant ?? "", _ => new InMemoryBackend()); + IStoreBackend BackendFor(string? tenant) => backends.GetOrAdd(tenant ?? "", _ => new InMemoryBackend()); using var mgr = ConfigManager.Create(c => c .UseConfiguration(rules => [ rules.For().FromObservable("""{ "Host": "smtp.default.com", "Port": 25 }"""), - rules.For().FromLocalStorage((a, _) => BackendFor(a.Tenant)).TenantScoped(), + rules.For().FromStore((a, _) => BackendFor(a.Tenant)).TenantScoped(), ]) .UseDebounce(25)); @@ -53,7 +53,7 @@ public async Task LocalStorageOverlay_IsPerTenant_WithDistinctBackends() await tenants.InitializeTenantAsync("B"); // Tenant A writes Port=587 into ITS OWN overlay store. - var storageA = mgr.GetLocalStorageForTenant("A"); + var storageA = mgr.GetWritableStoreForTenant("A"); await storageA.SetAsync(x => x.Port, 587); await TenantWait.UntilAsync(() => mgr.GetConfigForTenant("A")?.Port == 587, "tenant A override applied"); @@ -64,12 +64,12 @@ public async Task LocalStorageOverlay_IsPerTenant_WithDistinctBackends() // (2) Tenant B is UNAFFECTED — its store is a distinct backend instance. Assert.Equal(25, mgr.GetConfigForTenant("B")!.Port); - Assert.Null(await mgr.GetLocalStorageForTenant("B").ReadAsync()); + Assert.Null(await mgr.GetWritableStoreForTenant("B").ReadAsync()); // (3) Base-vs-effective provenance for A, computed over the tenant pipeline. var entries = await storageA.DescribeAsync(); var port = Assert.Single(entries, e => e.KeyPath == "Port"); - Assert.True(port.IsOverridden); + Assert.True(port.IsSet); Assert.Equal(25, port.BaseValue!.Value.GetInt32()); Assert.Equal(587, port.EffectiveValue!.Value.GetInt32()); } diff --git a/src/tests/Cocoar.Configuration.Providers.Tests/SecretEncryptionKeyProviderTests.cs b/src/tests/Cocoar.Configuration.Providers.Tests/SecretEncryptionKeyProviderTests.cs index 8c4246a..ea013c8 100644 --- a/src/tests/Cocoar.Configuration.Providers.Tests/SecretEncryptionKeyProviderTests.cs +++ b/src/tests/Cocoar.Configuration.Providers.Tests/SecretEncryptionKeyProviderTests.cs @@ -2,9 +2,9 @@ using System.Text.Json; using Cocoar.Configuration.Core; using Cocoar.Configuration.DI; -using Cocoar.Configuration.LocalStorage; +using Cocoar.Configuration.WritableStore; using Cocoar.Configuration.Providers; -using Cocoar.Configuration.Providers.Tests.LocalStorage; +using Cocoar.Configuration.Providers.Tests.WritableStore; using Cocoar.Configuration.Providers.Tests.TestUtilities; using Cocoar.Configuration.Rules; using Cocoar.Configuration.Secrets; @@ -97,7 +97,7 @@ public async Task PublishedPublicKey_EncryptsValue_ServerDecryptsToOriginal() .UseConfiguration(rules => new ConfigRule[] { rules.For().FromStaticJson("{}"), - rules.For().FromLocalStorage(backend), + rules.For().FromStore(backend), }) .UseSecretsSetup(secrets => secrets.UseCertificateFromFile(pfxPath).WithKeyId(kid))); @@ -113,7 +113,7 @@ public async Task PublishedPublicKey_EncryptsValue_ServerDecryptsToOriginal() var envelope = EncryptWithPublicKey(rsaPublic, kid, secretValue); // 3. Server stores the envelope and decrypts with the matching private key. - var storage = provider.GetRequiredService>(); + var storage = provider.GetRequiredService>(); var manager = provider.GetRequiredService(); await storage.SetSecretAsync(x => x.ApiKey!, envelope); diff --git a/src/tests/Cocoar.Configuration.Providers.Tests/LocalStorage/FileStorageBackendTests.cs b/src/tests/Cocoar.Configuration.Providers.Tests/WritableStore/FileStoreBackendTests.cs similarity index 88% rename from src/tests/Cocoar.Configuration.Providers.Tests/LocalStorage/FileStorageBackendTests.cs rename to src/tests/Cocoar.Configuration.Providers.Tests/WritableStore/FileStoreBackendTests.cs index 8b464e6..cec7545 100644 --- a/src/tests/Cocoar.Configuration.Providers.Tests/LocalStorage/FileStorageBackendTests.cs +++ b/src/tests/Cocoar.Configuration.Providers.Tests/WritableStore/FileStoreBackendTests.cs @@ -4,16 +4,16 @@ using Cocoar.Configuration.Providers.Tests.TestUtilities; using Xunit; -namespace Cocoar.Configuration.Providers.Tests.LocalStorage; +namespace Cocoar.Configuration.Providers.Tests.WritableStore; [Trait("Type", "Unit")] -public class FileStorageBackendTests +public class FileStoreBackendTests { [Fact] public async Task ReadAsync_MissingKey_ReturnsNull() { using var dir = TempDirectoryHelper.Create(); - var backend = new FileStorageBackend(dir.Path); + var backend = new FileStoreBackend(dir.Path); Assert.Null(await backend.ReadAsync("nope")); } @@ -22,7 +22,7 @@ public async Task ReadAsync_MissingKey_ReturnsNull() public async Task WriteThenRead_RoundTrips() { using var dir = TempDirectoryHelper.Create(); - var backend = new FileStorageBackend(dir.Path); + var backend = new FileStoreBackend(dir.Path); var payload = Encoding.UTF8.GetBytes("{\"a\":1}"); await backend.WriteAsync("key", payload); @@ -36,7 +36,7 @@ public async Task WriteThenRead_RoundTrips() public async Task Write_LeavesNoTempFiles() { using var dir = TempDirectoryHelper.Create(); - var backend = new FileStorageBackend(dir.Path); + var backend = new FileStoreBackend(dir.Path); await backend.WriteAsync("key", Encoding.UTF8.GetBytes("{\"a\":1}")); @@ -47,7 +47,7 @@ public async Task Write_LeavesNoTempFiles() public async Task ConcurrentWrites_NeverCorrupt() { using var dir = TempDirectoryHelper.Create(); - var backend = new FileStorageBackend(dir.Path); + var backend = new FileStoreBackend(dir.Path); // Direct, unsynchronized concurrent writes to the same key. The per-write GUID temp guarantees no // writer clobbers another's intermediate file, so the destination is always a complete document. @@ -80,7 +80,7 @@ public async Task ConcurrentWrites_NeverCorrupt() public async Task ConcurrentStoreWrites_AreSerializedAndConsistent() { using var dir = TempDirectoryHelper.Create(); - using var store = new LocalStorageStore(new FileStorageBackend(dir.Path), "key"); + using var store = new WritableStoreState(new FileStoreBackend(dir.Path), "key"); // The real production path: writes go through the store's write lock and never throw or corrupt. var writes = Enumerable.Range(0, 50).Select(i => diff --git a/src/tests/Cocoar.Configuration.Providers.Tests/LocalStorage/OverlayPathResolverTests.cs b/src/tests/Cocoar.Configuration.Providers.Tests/WritableStore/OverlayPathResolverTests.cs similarity index 96% rename from src/tests/Cocoar.Configuration.Providers.Tests/LocalStorage/OverlayPathResolverTests.cs rename to src/tests/Cocoar.Configuration.Providers.Tests/WritableStore/OverlayPathResolverTests.cs index b659b8e..47b0da4 100644 --- a/src/tests/Cocoar.Configuration.Providers.Tests/LocalStorage/OverlayPathResolverTests.cs +++ b/src/tests/Cocoar.Configuration.Providers.Tests/WritableStore/OverlayPathResolverTests.cs @@ -1,7 +1,7 @@ using Cocoar.Configuration.Providers; using Xunit; -namespace Cocoar.Configuration.Providers.Tests.LocalStorage; +namespace Cocoar.Configuration.Providers.Tests.WritableStore; public class OverlayPathResolverTests { diff --git a/src/tests/Cocoar.Configuration.Providers.Tests/LocalStorage/OverlayTestSupport.cs b/src/tests/Cocoar.Configuration.Providers.Tests/WritableStore/OverlayTestSupport.cs similarity index 91% rename from src/tests/Cocoar.Configuration.Providers.Tests/LocalStorage/OverlayTestSupport.cs rename to src/tests/Cocoar.Configuration.Providers.Tests/WritableStore/OverlayTestSupport.cs index 4787030..abca3f4 100644 --- a/src/tests/Cocoar.Configuration.Providers.Tests/LocalStorage/OverlayTestSupport.cs +++ b/src/tests/Cocoar.Configuration.Providers.Tests/WritableStore/OverlayTestSupport.cs @@ -2,7 +2,7 @@ using Cocoar.Configuration.Providers; using Cocoar.Configuration.Secrets.SecretTypes; -namespace Cocoar.Configuration.Providers.Tests.LocalStorage; +namespace Cocoar.Configuration.Providers.Tests.WritableStore; public sealed class SmtpSettings { @@ -36,7 +36,7 @@ public sealed class IndexableSettings } /// In-memory storage backend for deterministic overlay tests (no file I/O). -public sealed class InMemoryBackend : IStorageBackend +public sealed class InMemoryBackend : IStoreBackend { private byte[]? _data; diff --git a/src/tests/Cocoar.Configuration.Providers.Tests/LocalStorage/SparseOverlayMutatorTests.cs b/src/tests/Cocoar.Configuration.Providers.Tests/WritableStore/SparseOverlayMutatorTests.cs similarity index 98% rename from src/tests/Cocoar.Configuration.Providers.Tests/LocalStorage/SparseOverlayMutatorTests.cs rename to src/tests/Cocoar.Configuration.Providers.Tests/WritableStore/SparseOverlayMutatorTests.cs index c92aadf..ca4cefc 100644 --- a/src/tests/Cocoar.Configuration.Providers.Tests/LocalStorage/SparseOverlayMutatorTests.cs +++ b/src/tests/Cocoar.Configuration.Providers.Tests/WritableStore/SparseOverlayMutatorTests.cs @@ -5,7 +5,7 @@ using Cocoar.Json.Mutable; using Xunit; -namespace Cocoar.Configuration.Providers.Tests.LocalStorage; +namespace Cocoar.Configuration.Providers.Tests.WritableStore; public class SparseOverlayMutatorTests { diff --git a/src/tests/Cocoar.Configuration.Providers.Tests/LocalStorage/LocalStorageOverlayEndToEndTests.cs b/src/tests/Cocoar.Configuration.Providers.Tests/WritableStore/WritableStoreOverlayEndToEndTests.cs similarity index 91% rename from src/tests/Cocoar.Configuration.Providers.Tests/LocalStorage/LocalStorageOverlayEndToEndTests.cs rename to src/tests/Cocoar.Configuration.Providers.Tests/WritableStore/WritableStoreOverlayEndToEndTests.cs index dc2d947..72a68c5 100644 --- a/src/tests/Cocoar.Configuration.Providers.Tests/LocalStorage/LocalStorageOverlayEndToEndTests.cs +++ b/src/tests/Cocoar.Configuration.Providers.Tests/WritableStore/WritableStoreOverlayEndToEndTests.cs @@ -1,6 +1,6 @@ using Cocoar.Configuration.Core; using Cocoar.Configuration.DI; -using Cocoar.Configuration.LocalStorage; +using Cocoar.Configuration.WritableStore; using Cocoar.Configuration.Providers; using Cocoar.Configuration.Providers.Tests.TestUtilities; using Cocoar.Configuration.Reactive; @@ -8,16 +8,16 @@ using Microsoft.Extensions.DependencyInjection; using Xunit; -namespace Cocoar.Configuration.Providers.Tests.LocalStorage; +namespace Cocoar.Configuration.Providers.Tests.WritableStore; [Trait("Type", "Unit")] -public sealed class LocalStorageOverlayEndToEndTests : IDisposable +public sealed class WritableStoreOverlayEndToEndTests : IDisposable { private static readonly TimeSpan Timeout = TimeSpan.FromSeconds(5); private readonly List _disposables = new(); - private (ServiceProvider Provider, ILocalStorage Storage, ConfigManager Manager) Build( + private (ServiceProvider Provider, IWritableStore Storage, ConfigManager Manager) Build( string baseJson, InMemoryBackend? backend = null) { backend ??= new InMemoryBackend(); @@ -28,13 +28,13 @@ public sealed class LocalStorageOverlayEndToEndTests : IDisposable services.AddCocoarConfiguration(c => c.UseConfiguration(rules => new ConfigRule[] { rules.For().FromFile(file.FilePath).Required(), - rules.For().FromLocalStorage(backend), + rules.For().FromStore(backend), })); var provider = services.BuildServiceProvider(); _disposables.Add(provider); - var storage = provider.GetRequiredService>(); + var storage = provider.GetRequiredService>(); var manager = provider.GetRequiredService(); return (provider, storage, manager); } @@ -140,12 +140,12 @@ await ActiveWaitHelpers.WaitUntilAsync( var entries = await storage.DescribeAsync(); var port = Assert.Single(entries, e => e.KeyPath == "Port"); - Assert.True(port.IsOverridden); + Assert.True(port.IsSet); Assert.Equal(25, port.BaseValue!.Value.GetInt32()); Assert.Equal(587, port.EffectiveValue!.Value.GetInt32()); var host = Assert.Single(entries, e => e.KeyPath == "Host"); - Assert.False(host.IsOverridden); + Assert.False(host.IsSet); Assert.Equal("smtp.default.com", host.BaseValue!.Value.GetString()); Assert.Equal("smtp.default.com", host.EffectiveValue!.Value.GetString()); } @@ -190,8 +190,8 @@ public void BothInterfaces_ResolveToSameSingletonInstance() { var (provider, storage, _) = Build("{\"Port\":25}"); - var overlay = provider.GetRequiredService>(); - var storageAgain = provider.GetRequiredService>(); + var overlay = provider.GetRequiredService>(); + var storageAgain = provider.GetRequiredService>(); Assert.Same(storage, storageAgain); // singleton Assert.Same(storage.Overlay, overlay); // overlay resolves to the same adapter diff --git a/src/tests/Cocoar.Configuration.Providers.Tests/LocalStorage/LocalStorageSecretEnvelopeTests.cs b/src/tests/Cocoar.Configuration.Providers.Tests/WritableStore/WritableStoreSecretEnvelopeTests.cs similarity index 88% rename from src/tests/Cocoar.Configuration.Providers.Tests/LocalStorage/LocalStorageSecretEnvelopeTests.cs rename to src/tests/Cocoar.Configuration.Providers.Tests/WritableStore/WritableStoreSecretEnvelopeTests.cs index 3bf58bb..b691bf7 100644 --- a/src/tests/Cocoar.Configuration.Providers.Tests/LocalStorage/LocalStorageSecretEnvelopeTests.cs +++ b/src/tests/Cocoar.Configuration.Providers.Tests/WritableStore/WritableStoreSecretEnvelopeTests.cs @@ -3,7 +3,7 @@ using System.Text.Json.Nodes; using Cocoar.Configuration.Core; using Cocoar.Configuration.DI; -using Cocoar.Configuration.LocalStorage; +using Cocoar.Configuration.WritableStore; using Cocoar.Configuration.Providers; using Cocoar.Configuration.Providers.Tests.TestUtilities; using Cocoar.Configuration.Rules; @@ -13,10 +13,10 @@ using Microsoft.Extensions.DependencyInjection; using Xunit; -namespace Cocoar.Configuration.Providers.Tests.LocalStorage; +namespace Cocoar.Configuration.Providers.Tests.WritableStore; [Trait("Type", "Unit")] -public sealed class LocalStorageSecretEnvelopeTests +public sealed class WritableStoreSecretEnvelopeTests { public sealed class VaultConfig { @@ -40,12 +40,12 @@ public async Task SetSecretAsync_StoresEncryptedEnvelope_DecryptsToOriginalValue .UseConfiguration(rules => new ConfigRule[] { rules.For().FromStaticJson("{}"), - rules.For().FromLocalStorage(backend), + rules.For().FromStore(backend), }) .UseSecretsSetup(secrets => secrets.UseCertificateFromFile(pfxPath).WithKeyId(kid))); using var provider = services.BuildServiceProvider(); - var storage = provider.GetRequiredService>(); + var storage = provider.GetRequiredService>(); var manager = provider.GetRequiredService(); await storage.SetSecretAsync(x => x.ApiKey!, envelope); @@ -68,7 +68,7 @@ await ActiveWaitHelpers.WaitUntilAsync( public async Task SetSecretEnvelopeAsync_RejectsPlaintext() { using var provider = BuildMinimalProvider(); - var overlay = provider.GetRequiredService>(); + var overlay = provider.GetRequiredService>(); // A bare string is not a cocoar.secret envelope → rejected before anything is stored. await Assert.ThrowsAsync( @@ -79,7 +79,7 @@ await Assert.ThrowsAsync( public void SetAsync_OnSecretMember_StillThrowsNotSupported() { using var provider = BuildMinimalProvider(); - var storage = provider.GetRequiredService>(); + var storage = provider.GetRequiredService>(); // The normal typed SetAsync must keep rejecting secret members (no plaintext into the overlay). Assert.Throws( @@ -93,7 +93,7 @@ private static ServiceProvider BuildMinimalProvider() services.AddCocoarConfiguration(c => c.UseConfiguration(rules => new ConfigRule[] { rules.For().FromStaticJson("{}"), - rules.For().FromLocalStorage(backend), + rules.For().FromStore(backend), })); return services.BuildServiceProvider(); } diff --git a/src/tests/Cocoar.Configuration.ServiceBacked.Tests/ServiceBackedActivationTests.cs b/src/tests/Cocoar.Configuration.ServiceBacked.Tests/ServiceBackedActivationTests.cs index 4a92ed2..3116327 100644 --- a/src/tests/Cocoar.Configuration.ServiceBacked.Tests/ServiceBackedActivationTests.cs +++ b/src/tests/Cocoar.Configuration.ServiceBacked.Tests/ServiceBackedActivationTests.cs @@ -1,5 +1,5 @@ using Cocoar.Configuration.Core; -using Cocoar.Configuration.DI; // FromStorage, UseServiceBackedConfiguration, ActivateServiceBackedConfigurationAsync +using Cocoar.Configuration.DI; // FromStore, UseServiceBackedConfiguration, ActivateServiceBackedConfigurationAsync using Cocoar.Configuration.Providers; // FromStaticJson using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; // IHostedService @@ -25,7 +25,7 @@ public async Task ManualActivation_OnPlainContainer_ActivatesLayer2() ]) .UseServiceBackedConfiguration(rules => [ - rules.For().FromStorage((_, _) => new SeededBackend("""{ "Value": "stored" }""")), + rules.For().FromStore((_, _) => new SeededBackend("""{ "Value": "stored" }""")), ]) .UseDebounce(25)); @@ -48,7 +48,7 @@ public async Task ManualActivation_IsIdempotent() .UseConfiguration(rules => [ rules.For().FromStaticJson("""{ "Value": "base" }""") ]) .UseServiceBackedConfiguration(rules => [ - rules.For().FromStorage((_, _) => new SeededBackend("""{ "Value": "stored" }""")), + rules.For().FromStore((_, _) => new SeededBackend("""{ "Value": "stored" }""")), ]) .UseDebounce(25)); @@ -79,7 +79,7 @@ public void ServiceBacked_RegistersExactlyOneActivationHostedService() .UseConfiguration(rules => [ rules.For().FromStaticJson("""{ "Value": "base" }""") ]) .UseServiceBackedConfiguration(rules => [ - rules.For().FromStorage((_, _) => new SeededBackend("{}")), + rules.For().FromStore((_, _) => new SeededBackend("{}")), ])); Assert.Single(services, d => d.ServiceType == typeof(IHostedService)); @@ -95,7 +95,7 @@ public async Task ActivationGate_SurvivesATrailingUserWhen_FluentOrderProof() [ // A user .When() AFTER the sp-overload must NOT clobber the activation gate. rules.For() - .FromStorage((_, _) => new SeededBackend("""{ "Value": "stored" }""")) + .FromStore((_, _) => new SeededBackend("""{ "Value": "stored" }""")) .When(_ => true), ]) .UseDebounce(25)); @@ -110,7 +110,7 @@ public async Task ActivationGate_SurvivesATrailingUserWhen_FluentOrderProof() Assert.Equal("stored", mgr.GetConfig()!.Value); } - // NOTE: using FromStorage / FromHttp((sp,a)=>…) outside UseServiceBackedConfiguration is now a COMPILE error + // NOTE: using FromStore / FromHttp((sp,a)=>…) outside UseServiceBackedConfiguration is now a COMPILE error // (those overloads target ServiceBackedProviderBuilder, which UseConfiguration's plain // TypedProviderBuilder is not) — there is no longer a runtime-throw path to test. } diff --git a/src/tests/Cocoar.Configuration.ServiceBacked.Tests/ServiceBackedReviewRegressionTests.cs b/src/tests/Cocoar.Configuration.ServiceBacked.Tests/ServiceBackedReviewRegressionTests.cs index cad8ea3..679d0bc 100644 --- a/src/tests/Cocoar.Configuration.ServiceBacked.Tests/ServiceBackedReviewRegressionTests.cs +++ b/src/tests/Cocoar.Configuration.ServiceBacked.Tests/ServiceBackedReviewRegressionTests.cs @@ -94,7 +94,7 @@ public async Task TenantInitializedBeforeActivation_RecoversServiceBackedValue_A .UseServiceBackedConfiguration(rules => [ rules.For() - .FromStorage((sp, a) => sp.GetRequiredService().BackendFor(a.Tenant)) + .FromStore((sp, a) => sp.GetRequiredService().BackendFor(a.Tenant)) .TenantScoped(), ]) .UseDebounce(25)); @@ -157,7 +157,7 @@ public async Task ConcurrentActivations_BothObserveCommittedLayer2() .UseConfiguration(rules => [ rules.For().FromStaticJson("""{ "Value": "base" }""") ]) .UseServiceBackedConfiguration(rules => [ - rules.For().FromStorage((_, _) => new SeededBackend("""{ "Value": "stored" }""")), + rules.For().FromStore((_, _) => new SeededBackend("""{ "Value": "stored" }""")), ]) .UseDebounce(25)); diff --git a/src/tests/Cocoar.Configuration.ServiceBacked.Tests/ServiceBackedStorageTests.cs b/src/tests/Cocoar.Configuration.ServiceBacked.Tests/ServiceBackedStorageTests.cs index 66d1685..1be59dc 100644 --- a/src/tests/Cocoar.Configuration.ServiceBacked.Tests/ServiceBackedStorageTests.cs +++ b/src/tests/Cocoar.Configuration.ServiceBacked.Tests/ServiceBackedStorageTests.cs @@ -1,5 +1,5 @@ using Cocoar.Configuration.Core; -using Cocoar.Configuration.DI; // FromStorage, UseServiceBackedConfiguration +using Cocoar.Configuration.DI; // FromStore, UseServiceBackedConfiguration using Cocoar.Configuration.Providers; // FromStaticJson using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; @@ -7,7 +7,7 @@ namespace Cocoar.Configuration.ServiceBacked.Tests; /// -/// ADR-006 headline #2: FromStorage((sp,a)=>…).TenantScoped() — a DB-backed (Marten-style) source per +/// ADR-006 headline #2: FromStore((sp,a)=>…).TenantScoped() — a DB-backed (Marten-style) source per /// tenant. Proves the tenant gate and the sp gate compose: the rule runs only inside a tenant pipeline, post /// host start, sourcing a backend from the DI-managed store keyed by the tenant. /// @@ -31,7 +31,7 @@ public async Task MartenPerTenant_ComposesTenantGateAndServiceProviderGate() .UseServiceBackedConfiguration(rules => [ rules.For() - .FromStorage((sp, a) => sp.GetRequiredService().BackendFor(a.Tenant)) + .FromStore((sp, a) => sp.GetRequiredService().BackendFor(a.Tenant)) .TenantScoped(), ]) .UseDebounce(25)); @@ -77,7 +77,7 @@ public async Task TenantInitializedBeforeActivation_SeesLayer1Base_NotServiceBac .UseServiceBackedConfiguration(rules => [ rules.For() - .FromStorage((sp, a) => sp.GetRequiredService().BackendFor(a.Tenant)) + .FromStore((sp, a) => sp.GetRequiredService().BackendFor(a.Tenant)) .TenantScoped(), ]) .UseDebounce(25)); diff --git a/src/tests/Cocoar.Configuration.ServiceBacked.Tests/TestSupport.cs b/src/tests/Cocoar.Configuration.ServiceBacked.Tests/TestSupport.cs index 11a26c1..f9fa680 100644 --- a/src/tests/Cocoar.Configuration.ServiceBacked.Tests/TestSupport.cs +++ b/src/tests/Cocoar.Configuration.ServiceBacked.Tests/TestSupport.cs @@ -2,7 +2,7 @@ using System.Diagnostics; using System.Net; using System.Text; -using Cocoar.Configuration.Providers; // IStorageBackend +using Cocoar.Configuration.Providers; // IStoreBackend namespace Cocoar.Configuration.ServiceBacked.Tests; @@ -64,8 +64,8 @@ protected override Task SendAsync(HttpRequestMessage reques // ===== Storage test doubles ===== -/// An in-memory seeded with a fixed JSON body (read-only for tests). -internal sealed class SeededBackend : IStorageBackend +/// An in-memory seeded with a fixed JSON body (read-only for tests). +internal sealed class SeededBackend : IStoreBackend { private byte[]? _data; @@ -86,7 +86,7 @@ public Task WriteAsync(string key, byte[] data, CancellationToken ct = default) /// internal interface IFakeDocumentStore { - IStorageBackend BackendFor(string? tenant); + IStoreBackend BackendFor(string? tenant); IReadOnlyCollection RequestedTenants { get; } } @@ -95,7 +95,7 @@ internal sealed class FakeDocumentStore : IFakeDocumentStore private readonly ConcurrentDictionary _backends = new(); private readonly ConcurrentDictionary _requested = new(); - public IStorageBackend BackendFor(string? tenant) + public IStoreBackend BackendFor(string? tenant) { var key = tenant ?? ""; _requested.TryAdd(key, 0); diff --git a/src/tests/Cocoar.Configuration.ServiceBacked.Tests/ThirdPartyServiceBackedProviderTests.cs b/src/tests/Cocoar.Configuration.ServiceBacked.Tests/ThirdPartyServiceBackedProviderTests.cs index e5f6f7c..a485672 100644 --- a/src/tests/Cocoar.Configuration.ServiceBacked.Tests/ThirdPartyServiceBackedProviderTests.cs +++ b/src/tests/Cocoar.Configuration.ServiceBacked.Tests/ThirdPartyServiceBackedProviderTests.cs @@ -30,7 +30,7 @@ public sealed class InlineOptions(Func json) : IProviderConfiguration /// The JSON to emit — resolved lazily (so it can read a DI service at fetch time). public Func Json { get; } = json; - // Carries a closure over the IServiceProvider -> not shareable across rules (mirrors the HTTP/LocalStorage rule). + // Carries a closure over the IServiceProvider -> not shareable across rules (mirrors the HTTP/WritableStore rule). public string? GenerateProviderKey() => null; } diff --git a/website/.vitepress/config.ts b/website/.vitepress/config.ts index 0c80e77..ec86831 100644 --- a/website/.vitepress/config.ts +++ b/website/.vitepress/config.ts @@ -66,7 +66,7 @@ export default defineConfig({ { text: 'HTTP Polling', link: '/guide/providers/http-polling' }, { text: 'Microsoft IConfiguration', link: '/guide/providers/microsoft-adapter' }, { text: 'Static & Observable', link: '/guide/providers/static-observable' }, - { text: 'LocalStorage', link: '/guide/providers/localstorage' }, + { text: 'Writable Store', link: '/guide/providers/writable-store' }, { text: 'Custom Providers ', link: '/guide/providers/custom' }, ], }, diff --git a/website/changelog.md b/website/changelog.md index f0437f3..c3fc283 100644 --- a/website/changelog.md +++ b/website/changelog.md @@ -4,34 +4,34 @@ ### Added -**LocalStorage — writable override layer** +**WritableStore — writable override layer** - A writable, application-controlled layer for *overridable defaults*: the normal sources supply defaults; the app overrides individual values at runtime. -- `ILocalStorage` (type-safe facade) and `ILocalStorageOverlay` (raw key-path surface) in `Cocoar.Configuration.Abstractions` +- `IWritableStore` (type-safe facade) and `IWritableStoreOverlay` (raw key-path surface) in `Cocoar.Configuration.Abstractions` - **Sparse writes** — `SetAsync(x => x.Smtp.Port, value)` persists only the touched leaf; unset keys keep inheriting from lower layers - `ResetAsync(...)` removes an override (falls back to the inherited default); an explicit `null` override is distinct from reset -- `DescribeAsync()` returns per-key provenance (`OverrideEntry`: base, effective, `IsOverridden`) for management UIs -- `.FromLocalStorage()` rule extension; file-based backend by default, pluggable `IStorageBackend` -- `ILocalStorage` / `ILocalStorageOverlay` are DI-injectable (single shared singleton) — write your own endpoints with your own validation/normalization/logging -- Secret-typed members cannot be overridden via LocalStorage (throws `NotSupportedException`) +- `DescribeAsync()` returns per-key provenance (`StoreEntry`: base, effective, `IsSet`) for management UIs +- `.FromStore()` rule extension; file-based backend by default, pluggable `IStoreBackend` +- `IWritableStore` / `IWritableStoreOverlay` are DI-injectable (single shared singleton) — write your own endpoints with your own validation/normalization/logging +- Secret-typed members cannot be overridden via WritableStore (throws `NotSupportedException`) - `IProviderServiceRegistration` gained resolve-time factory registration support **Multi-Tenancy** (ADR-005) - The same configuration type resolves to different values per tenant, layered on a shared global base - `.TenantScoped()` rule marker + `Tenant` on `IConfigurationAccessor` — author one flat rule list (no second surface) - `ITenantConfigurationAccessor` lifecycle: `InitializeTenantAsync` / `EnsureTenantInitializedAsync` / `RemoveTenantAsync` -- Per-tenant access: `GetConfigForTenant` / `GetReactiveConfigForTenant` / `GetFeatureFlagsForTenant` / `GetEntitlementsForTenant` / `GetLocalStorageForTenant` +- Per-tenant access: `GetConfigForTenant` / `GetReactiveConfigForTenant` / `GetFeatureFlagsForTenant` / `GetEntitlementsForTenant` / `GetWritableStoreForTenant` - Tenant-only types excluded from the global DI plan; per-tenant flags/entitlements need no source-generator change - ASP.NET Core: scoped `ITenantReactiveConfig` + `ITenantContext`; `MapTenantFeatureFlagEndpoints()` / `MapTenantEntitlementEndpoints()` **Service-Backed (DI-aware) configuration** (ADR-006) - Two-layer model: eager `UseConfiguration` (Layer 1) + lazy `UseServiceBackedConfiguration` (Layer 2), whose provider factories receive the `IServiceProvider` -- `FromStorage((sp, a) => …)`, `FromHttp((sp, a) => …)`, `FromService(s => …)` — use `IHttpClientFactory` / Marten / EF without giving up the no-DI core +- `FromStore((sp, a) => …)`, `FromHttp((sp, a) => …)`, `FromService(s => …)` — use `IHttpClientFactory` / Marten / EF without giving up the no-DI core - Activated on host start via `IHostedLifecycleService` (a recompute, never a rebuild — live reactive views stay valid) - Public `ServiceBackedProviderBuilder` seam for third-party `(sp, a)` provider overloads **Secrets — encryption-key publishing** - Publish the public half of the secrets encryption key (`ISecretEncryptionKeyProvider`; ASP.NET Core `MapSecretEncryptionKeyEndpoints()` at `/.well-known/cocoar/encryption-keys`) so a browser/CLI can build `cocoar.secret` envelopes -- `SecretEnvelope` typed secret-overlay writes; LocalStorage `SetSecretAsync` / `SetSecretEnvelopeAsync` accept pre-encrypted envelopes +- `SecretEnvelope` typed secret-overlay writes; WritableStore `SetSecretAsync` / `SetSecretEnvelopeAsync` accept pre-encrypted envelopes **Custom-provider authoring** - Public `ProviderObservable` / `ProviderDisposable` helpers (in `Cocoar.Configuration.Providers.Abstractions`) for a provider's change stream without referencing System.Reactive diff --git a/website/guide/di/service-backed.md b/website/guide/di/service-backed.md index 17330a4..e892a58 100644 --- a/website/guide/di/service-backed.md +++ b/website/guide/di/service-backed.md @@ -31,7 +31,7 @@ services.AddCocoarConfiguration(c => c (sp, a) => sp.GetRequiredService().CreateClient("cocoar-config"), "logging.json", pollInterval: TimeSpan.FromSeconds(30)), - rules.For().FromStorage( + rules.For().FromStore( (sp, a) => new MartenConfigBackend(sp.GetRequiredService(), a.Tenant)) .TenantScoped(), ])); @@ -60,12 +60,12 @@ services.AddCocoarConfiguration(c => c The plain `FromHttp(url)` overload (which `new`s its own `HttpClient`) stays available for Layer 1 / no-DI. -## DB-backed config with `FromStorage` +## DB-backed config with `FromStore` -`FromStorage((sp, a) => IStorageBackend)` reuses Cocoar's storage pipeline: implement `IStorageBackend` (`ReadAsync`/`WriteAsync` over your store) and source it from DI. Combine with `.TenantScoped()` for **DB-config-per-tenant** — the tenant gate and the service-provider gate compose, so the rule runs only inside a tenant pipeline, after the host has started. +`FromStore((sp, a) => IStoreBackend)` reuses Cocoar's storage pipeline: implement `IStoreBackend` (`ReadAsync`/`WriteAsync` over your store) and source it from DI. Combine with `.TenantScoped()` for **DB-config-per-tenant** — the tenant gate and the service-provider gate compose, so the rule runs only inside a tenant pipeline, after the host has started. ```csharp -public sealed class MartenConfigBackend(IDocumentStore store, string? tenant) : IStorageBackend +public sealed class MartenConfigBackend(IDocumentStore store, string? tenant) : IStoreBackend { public async Task ReadAsync(string key, CancellationToken ct = default) { @@ -95,7 +95,7 @@ When the config simply **comes from a DI service** (no I/O source — an in-memo This is Cocoar's equivalent of Microsoft's `services.Configure((opts, dep) => …)` / an `IConfigureOptions` with an injected dependency — and the natural target when migrating those. The service is resolved at recompute time (after host start); the rule is dormant until then, like any Layer-2 rule, and composes with `.TenantScoped()`. ::: warning Synchronous / in-memory only -`FromService` snapshots once per recompute (no change detection) and the projection is synchronous. For I/O-bound sources (DB, HTTP, Key Vault) use an async provider — `FromStorage`, `FromHttp((sp,a)=>…)`, or a custom provider — rather than blocking inside the projection. +`FromService` snapshots once per recompute (no change detection) and the projection is synchronous. For I/O-bound sources (DB, HTTP, Key Vault) use an async provider — `FromStore`, `FromHttp((sp,a)=>…)`, or a custom provider — rather than blocking inside the projection. ::: ## Lifecycle & the readiness contract @@ -154,7 +154,7 @@ public static ProviderRuleBuilder FromMyDb( _ => MyQuery.Default); ``` -Two things make a provider service-backed: (1) author this `(sp, a)` overload on `ServiceBackedProviderBuilder`, and (2) have the provider's **options carry** the resolved artifact (HTTP carries a `ClientFactory`; LocalStorage an `IStorageBackend`). The provider class itself (`ConfigurationProvider<,>`) stays DI-free — and a service-backed provider is usually its **own** small provider, not a no-DI one retrofitted with fallbacks. See [Building Custom Providers → Service-Backed Providers](/guide/providers/custom#service-backed-providers-di-aware) for a full worked example. +Two things make a provider service-backed: (1) author this `(sp, a)` overload on `ServiceBackedProviderBuilder`, and (2) have the provider's **options carry** the resolved artifact (HTTP carries a `ClientFactory`; WritableStore an `IStoreBackend`). The provider class itself (`ConfigurationProvider<,>`) stays DI-free — and a service-backed provider is usually its **own** small provider, not a no-DI one retrofitted with fallbacks. See [Building Custom Providers → Service-Backed Providers](/guide/providers/custom#service-backed-providers-di-aware) for a full worked example. Because these overloads target `ServiceBackedProviderBuilder`, using them inside the Layer-1 `UseConfiguration` (a plain `TypedProviderBuilder`) is a **compile error** — the type system, not a runtime check, keeps DI-backed loading out of Layer 1. diff --git a/website/guide/multi-tenancy/overview.md b/website/guide/multi-tenancy/overview.md index 7f18081..8e8de1e 100644 --- a/website/guide/multi-tenancy/overview.md +++ b/website/guide/multi-tenancy/overview.md @@ -50,7 +50,7 @@ var smtp = manager.GetConfigForTenant("acme"); // sync r var live = manager.GetReactiveConfigForTenant("acme"); // IReactiveConfig for this tenant var flags = manager.GetFeatureFlagsForTenant("acme"); var ents = manager.GetEntitlementsForTenant("acme"); -var store = manager.GetLocalStorageForTenant("acme"); // per-tenant write facade +var store = manager.GetWritableStoreForTenant("acme"); // per-tenant write facade ``` ### Not DI-injectable — by design @@ -102,21 +102,21 @@ app.MapTenantFeatureFlagEndpoints(); // GET /tenants/{tenant}/flags/{FlagClass app.MapTenantEntitlementEndpoints(); // GET /tenants/{tenant}/entitlements/{Class}/{Name} ``` -## Per-tenant LocalStorage +## Per-tenant WritableStore Give each tenant its own backend via the factory overload (the store is keyed by `accessor.Tenant`), and write through the per-tenant facade: ```csharp -rules.For().FromLocalStorage((a, _) => BackendFor(a.Tenant)).TenantScoped() +rules.For().FromStore((a, _) => BackendFor(a.Tenant)).TenantScoped() -await manager.GetLocalStorageForTenant("acme").SetAsync(x => x.Port, 587); +await manager.GetWritableStoreForTenant("acme").SetAsync(x => x.Port, 587); ``` A write triggers only that tenant's recompute; other tenants are untouched. Provenance (`DescribeAsync`) is computed over the tenant's own layers. ### DB-backed config per tenant -When the per-tenant source is a database (Marten / EF) reached through a DI-managed store, use `FromStorage((sp, a) => …).TenantScoped()` — the tenant gate and the service-provider gate compose, so the rule runs only inside a tenant pipeline, after the host has started. See [Service-Backed Configuration](/guide/di/service-backed#db-backed-config-with-fromstorage). +When the per-tenant source is a database (Marten / EF) reached through a DI-managed store, use `FromStore((sp, a) => …).TenantScoped()` — the tenant gate and the service-provider gate compose, so the rule runs only inside a tenant pipeline, after the host has started. See [Service-Backed Configuration](/guide/di/service-backed#db-backed-config-with-fromstorage). ## Per-tenant secrets diff --git a/website/guide/providers/overview.md b/website/guide/providers/overview.md index 2c8e055..b8b6874 100644 --- a/website/guide/providers/overview.md +++ b/website/guide/providers/overview.md @@ -29,7 +29,7 @@ On failure, providers return an empty JSON object `{}` — never null. This mean | [Command Line](/guide/providers/command-line) | `.FromCommandLine("--prefix")` | No | Core | | [Static JSON](/guide/providers/static-observable#static-json) | `.FromStaticJson("{...}")` | No | Core | | [Observable](/guide/providers/static-observable#observable) | `.FromObservable(obs)` | Yes | Core | -| [LocalStorage](/guide/providers/localstorage) | `.FromLocalStorage()` | Yes (on write) | Core | +| [Writable Store](/guide/providers/writable-store) | `.FromStore()` | Yes (on write) | Core | | [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/providers/localstorage.md b/website/guide/providers/writable-store.md similarity index 72% rename from website/guide/providers/localstorage.md rename to website/guide/providers/writable-store.md index 51a9416..d7110f4 100644 --- a/website/guide/providers/localstorage.md +++ b/website/guide/providers/writable-store.md @@ -1,6 +1,6 @@ -# LocalStorage Provider +# Writable Store Provider -LocalStorage is a **writable, application-controlled override layer**. Every other provider is an external source you read from — LocalStorage is the one layer your application can write to at runtime. +The writable store is a **writable, application-controlled override layer**. Every other provider is an external source you read from — it is the one layer your application can write to at runtime. Its purpose is **overridable defaults**: the normal sources (files, environment, …) supply defaults, and the application overrides *individual* values at runtime — from an admin UI, an API, or a background job — while everything it doesn't touch keeps inheriting from the lower layers. @@ -8,15 +8,15 @@ Its purpose is **overridable defaults**: the normal sources (files, environment, rules => [ rules.For().FromFile("appsettings.json"), // defaults - rules.For().FromLocalStorage(), // app-controlled overrides (placed last → wins) + rules.For().FromStore(), // app-controlled overrides (placed last → wins) ] ``` -Position matters: place the LocalStorage rule **after** the rules whose values it should override. +Position matters: place the writable-store rule **after** the rules whose values it should override. ## Sparse overrides -LocalStorage persists a **sparse** JSON object — only the leaves you explicitly set. Everything else is physically absent and therefore inherits from the lower layers through the normal byte-level merge. +The writable store persists a **sparse** JSON object — only the leaves you explicitly set. Everything else is physically absent and therefore inherits from the lower layers through the normal byte-level merge. Given the defaults `{ "Host": "smtp.default.com", "Port": 25, "UseSsl": false }`, after: @@ -37,10 +37,10 @@ This is the key difference from a "save the whole object" store: setting one val ## Reading and writing -Inject `ILocalStorage` (registered as a **Singleton**, thread-safe) to override values at runtime: +Inject `IWritableStore` (registered as a **Singleton**, thread-safe) to override values at runtime: ```csharp -public class SettingsController(ILocalStorage storage) +public class SettingsController(IWritableStore storage) { // Override a single value — only this leaf is persisted; a recompute fires // and IReactiveConfig emits the new effective value. @@ -95,11 +95,11 @@ JsonNode? raw = await storage.Overlay.ReadOverlayAsync(); // the raw s ```csharp foreach (var entry in await storage.DescribeAsync()) { - // entry.KeyPath, entry.BaseValue, entry.EffectiveValue, entry.IsOverridden + // entry.KeyPath, entry.BaseValue, entry.EffectiveValue, entry.IsSet } ``` -| KeyPath | BaseValue | EffectiveValue | IsOverridden | +| KeyPath | BaseValue | EffectiveValue | IsSet | |---|---|---|---| | `Host` | `"smtp.default.com"` | `"smtp.default.com"` | `false` | | `Port` | `25` | `587` | `true` | @@ -109,7 +109,7 @@ foreach (var entry in await storage.DescribeAsync()) ## Raw overlay surface -For dynamic or non-expressible paths, use `ILocalStorageOverlay` (also resolvable directly from DI, or via `storage.Overlay`). Key paths are dotted; their segments must match the persisted JSON property names: +For dynamic or non-expressible paths, use `IWritableStoreOverlay` (also resolvable directly from DI, or via `storage.Overlay`). Key paths are dotted; their segments must match the persisted JSON property names: ```csharp await storage.Overlay.SetAsync("Smtp.Port", JsonValue.Create(587)); @@ -125,12 +125,12 @@ The typed facade aligns key casing to the lower layers for you; with the raw sur ## Writing your own endpoints -There is no built-in REST surface — and that's deliberate. Writes are where *your* rules live (validation, normalization, authorization, audit logging, request shape), so the library gives you the injectable primitive and you own the endpoint. Inject `ILocalStorage` (or `ILocalStorageOverlay`) anywhere and do your work *before* writing: +There is no built-in REST surface — and that's deliberate. Writes are where *your* rules live (validation, normalization, authorization, audit logging, request shape), so the library gives you the injectable primitive and you own the endpoint. Inject `IWritableStore` (or `IWritableStoreOverlay`) anywhere and do your work *before* writing: ```csharp app.MapPut("/admin/smtp/port", async ( int port, - ILocalStorage storage, + IWritableStore storage, ILogger log) => { if (port is < 1 or > 65535) // validate @@ -143,26 +143,26 @@ app.MapPut("/admin/smtp/port", async ( .RequireAuthorization("AdminPolicy"); // Expose the provenance view for a management UI: -app.MapGet("/admin/smtp", (ILocalStorage storage, CancellationToken ct) => +app.MapGet("/admin/smtp", (IWritableStore storage, CancellationToken ct) => storage.DescribeAsync(ct)); ``` -For a generic admin UI that sets arbitrary keys, inject the raw `ILocalStorageOverlay` and pass the dotted key path and a `JsonNode` yourself (your code is responsible for validating the path and value). +For a generic admin UI that sets arbitrary keys, inject the raw `IWritableStoreOverlay` and pass the dotted key path and a `JsonNode` yourself (your code is responsible for validating the path and value). -Both `ILocalStorage` and `ILocalStorageOverlay` are registered by `AddCocoarConfiguration` as the **same** singleton instance, so either can be injected into controllers or minimal-API handlers. +Both `IWritableStore` and `IWritableStoreOverlay` are registered by `AddCocoarConfiguration` as the **same** singleton instance, so either can be injected into controllers or minimal-API handlers. -## Storage backends +## Store backends -By default, overrides are persisted as a JSON file under `{AppContext.BaseDirectory}/.cocoar/localStorage/`, written atomically (temp-file-then-rename). Plug in your own store by implementing `IStorageBackend`: +By default, overrides are persisted as a JSON file under `{AppContext.BaseDirectory}/.cocoar/store/`, written atomically (temp-file-then-rename). Plug in your own store by implementing `IStoreBackend`: ```csharp -rules.For().FromLocalStorage(new MyDatabaseBackend()); +rules.For().FromStore(new MyDatabaseBackend()); ``` A config-aware overload receives the current configuration and backend, for backends whose connection depends on earlier rules (e.g. a connection string): ```csharp -rules.For().FromLocalStorage((accessor, current) => +rules.For().FromStore((accessor, current) => current ?? new DbBackend(accessor.GetConfig()!.ConnectionString)); ``` @@ -171,7 +171,7 @@ rules.For().FromLocalStorage((accessor, current) => ## How it works ``` -ILocalStorage.SetAsync(x => x.Port, 587) +IWritableStore.SetAsync(x => x.Port, 587) → resolve "Port" to a dotted key path + align casing to the lower layers → atomically read-merge-write the sparse overlay leaf to the backend → signal the provider's change observable @@ -179,4 +179,4 @@ ILocalStorage.SetAsync(x => x.Port, 587) → IReactiveConfig emits the new effective value ``` -The read/merge path is identical to every other provider — LocalStorage only adds the write path. See the runnable [LocalStorageOverride example](https://github.com/cocoar-dev/cocoar.configuration/tree/develop/src/Examples/LocalStorageOverride) for an end-to-end walkthrough. +The read/merge path is identical to every other provider — the writable store only adds the write path. See the runnable [WritableStoreExample example](https://github.com/cocoar-dev/cocoar.configuration/tree/develop/src/Examples/WritableStoreExample) for an end-to-end walkthrough. diff --git a/website/guide/secrets/key-publishing.md b/website/guide/secrets/key-publishing.md index 629087d..fc64428 100644 --- a/website/guide/secrets/key-publishing.md +++ b/website/guide/secrets/key-publishing.md @@ -64,7 +64,7 @@ The list endpoint wraps these as `{ "keys": [ … ] }`. The `keys` field name is 2. Generate a random AES-256 DEK, encrypt the value with AES-GCM, wrap the DEK with RSA-OAEP-256, and assemble the `cocoar.secret` envelope (with `kid` stamped from the key). 3. Send the envelope to your server. It is stored as-is and decrypted only on `Secret.Open()`. -The envelope wire format is documented in [Custom Providers → Secrets](/guide/providers/custom#secrets-in-custom-providers). The same envelope can be written through a LocalStorage overlay via `SetSecretEnvelopeAsync` / `SetSecretAsync`. +The envelope wire format is documented in [Custom Providers → Secrets](/guide/providers/custom#secrets-in-custom-providers). The same envelope can be written through a WritableStore overlay via `SetSecretEnvelopeAsync` / `SetSecretAsync`. ## Availability From cc0f20d0f2c06740605204febc1d263cdc1ba21c Mon Sep 17 00:00:00 2001 From: Bernhard Windisch Date: Sat, 30 May 2026 23:27:15 +0200 Subject: [PATCH 06/18] docs: publish ADRs in the docs site under an "ADR" top-nav MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move docs/adr/*.md (all 6) into website/adr/ so they are first-class, searchable, navigable docs instead of out-of-site files linked via GitHub blob URLs (which 404'd pre-merge). Single source of truth; no drift. - New "ADR" top-nav entry + /adr/ sidebar + an overview index page - Guide links to ADR-005/006 converted from /blob/develop/ URLs to relative links; custom.md ADR-006 §9 mention linked - Escaped bare -style generics in ADR prose so VitePress (Vue) parses them (code fences / inline code left raw) - CLAUDE.md + root CHANGELOG link updated to website/adr/ VitePress build clean (no dead links). Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 2 +- CLAUDE.md | 14 +++++++------- website/.vitepress/config.ts | 15 +++++++++++++++ .../adr/ADR-001-capabilities-system.md | 2 +- .../adr/ADR-002-atomic-reactive-updates.md | 2 +- .../ADR-003-provider-consistency-empty-objects.md | 0 {docs => website}/adr/ADR-004-aggregate-rules.md | 0 .../adr/ADR-005-multi-tenant-configuration.md | 0 .../adr/ADR-006-di-aware-configuration.md | 0 website/adr/index.md | 12 ++++++++++++ website/guide/di/service-backed.md | 4 ++-- website/guide/multi-tenancy/overview.md | 2 +- website/guide/providers/custom.md | 2 +- 13 files changed, 41 insertions(+), 14 deletions(-) rename {docs => website}/adr/ADR-001-capabilities-system.md (96%) rename {docs => website}/adr/ADR-002-atomic-reactive-updates.md (96%) rename {docs => website}/adr/ADR-003-provider-consistency-empty-objects.md (100%) rename {docs => website}/adr/ADR-004-aggregate-rules.md (100%) rename {docs => website}/adr/ADR-005-multi-tenant-configuration.md (100%) rename {docs => website}/adr/ADR-006-di-aware-configuration.md (100%) create mode 100644 website/adr/index.md diff --git a/CHANGELOG.md b/CHANGELOG.md index c1a9827..ea0b9c7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -191,7 +191,7 @@ See [Migration Guide v4→v5](website/guide/migration/v4-to-v5.md) for all patte ## [4.1.0] - 2026-01-11 ### Fixed -- **Provider consistency bug**: Optional rules now consistently return empty objects with C# defaults when sources are unavailable, instead of inconsistently returning null. This fixes a bug where source-based providers (File, HTTP) behaved differently than collection-based providers (Environment, CommandLine). See [ADR-003](docs/adr/ADR-003-provider-consistency-empty-objects.md) for details. +- **Provider consistency bug**: Optional rules now consistently return empty objects with C# defaults when sources are unavailable, instead of inconsistently returning null. This fixes a bug where source-based providers (File, HTTP) behaved differently than collection-based providers (Environment, CommandLine). See [ADR-003](website/adr/ADR-003-provider-consistency-empty-objects.md) for details. - All providers now return `{}` on failure, resulting in configuration objects with C# property defaults - Failures are tracked via health monitoring with `Degraded` status - Eliminates need for workarounds like adding fake `FromEnvironment()` rules diff --git a/CLAUDE.md b/CLAUDE.md index 7933945..20e3bbd 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -116,17 +116,17 @@ Service descriptors are emitted in deterministic order (sorted by type full name ## Key Architecture Decisions Read these ADRs to understand important design choices: -- **ADR-001** (`docs/adr/`) - Capabilities system for cross-assembly extensibility -- **ADR-002** (`docs/adr/`) - Atomic reactive configuration updates (tuple semantics) -- **ADR-003** (`docs/adr/`) - Provider consistency (empty objects on failure) -- **ADR-004** (`docs/adr/`) - Aggregate rules with isolated execution boundary -- **ADR-005** (`docs/adr/`) - Multi-tenant configuration (per-tenant pipelines on a shared global base) -- **ADR-006** (`docs/adr/`) - DI-aware (service-backed) two-layer configuration +- **ADR-001** (`website/adr/`) - Capabilities system for cross-assembly extensibility +- **ADR-002** (`website/adr/`) - Atomic reactive configuration updates (tuple semantics) +- **ADR-003** (`website/adr/`) - Provider consistency (empty objects on failure) +- **ADR-004** (`website/adr/`) - Aggregate rules with isolated execution boundary +- **ADR-005** (`website/adr/`) - Multi-tenant configuration (per-tenant pipelines on a shared global base) +- **ADR-006** (`website/adr/`) - DI-aware (service-backed) two-layer configuration ## Documentation - `website/` - VitePress documentation site (single source of truth for user-facing docs) -- `docs/adr/` - Architecture Decision Records (ADR-001 through ADR-006) +- `website/adr/` - Architecture Decision Records (ADR-001 through ADR-006), published in the docs site under the **ADR** top-nav - `src/Examples/` - Runnable example projects demonstrating individual features ## Local Working Files diff --git a/website/.vitepress/config.ts b/website/.vitepress/config.ts index ec86831..6f50bb5 100644 --- a/website/.vitepress/config.ts +++ b/website/.vitepress/config.ts @@ -29,6 +29,7 @@ export default defineConfig({ nav: [ { text: 'Guide', link: '/guide/getting-started' }, { text: 'Reference', link: '/reference/packages' }, + { text: 'ADR', link: '/adr/' }, { text: 'Roadmap', link: '/roadmap/overview' }, { text: 'Changelog', link: '/changelog' }, { text: 'LLM Docs', link: '/llms-full.txt', target: '_blank' }, @@ -181,6 +182,20 @@ export default defineConfig({ ], }, ], + '/adr/': [ + { + text: 'Architecture Decision Records', + items: [ + { text: 'Overview', link: '/adr/' }, + { text: 'ADR-001 · Capabilities System', link: '/adr/ADR-001-capabilities-system' }, + { text: 'ADR-002 · Atomic Reactive Updates', link: '/adr/ADR-002-atomic-reactive-updates' }, + { text: 'ADR-003 · Provider Consistency', link: '/adr/ADR-003-provider-consistency-empty-objects' }, + { text: 'ADR-004 · Aggregate Rules', link: '/adr/ADR-004-aggregate-rules' }, + { text: 'ADR-005 · Multi-Tenant Configuration', link: '/adr/ADR-005-multi-tenant-configuration' }, + { text: 'ADR-006 · DI-aware Configuration', link: '/adr/ADR-006-di-aware-configuration' }, + ], + }, + ], }, socialLinks: [ diff --git a/docs/adr/ADR-001-capabilities-system.md b/website/adr/ADR-001-capabilities-system.md similarity index 96% rename from docs/adr/ADR-001-capabilities-system.md rename to website/adr/ADR-001-capabilities-system.md index c49bc8e..df395a8 100644 --- a/docs/adr/ADR-001-capabilities-system.md +++ b/website/adr/ADR-001-capabilities-system.md @@ -36,7 +36,7 @@ builder.AddCocoarConfiguration(rule => [ | Pattern | Why It Fails | |---------|--------------| -| **Dictionary** | No type safety, can't compose multiple metadata types, external global state | +| **Dictionary<object, object>** | No type safety, can't compose multiple metadata types, external global state | | **Reflection Attributes** | Compile-time only, can't configure same type differently in different contexts | | **Method Parameters** | Destroys fluent API, parameter explosion, not extensible from other assemblies | | **Builder Internal State** | Core must know about ALL extension metadata types → circular dependencies | diff --git a/docs/adr/ADR-002-atomic-reactive-updates.md b/website/adr/ADR-002-atomic-reactive-updates.md similarity index 96% rename from docs/adr/ADR-002-atomic-reactive-updates.md rename to website/adr/ADR-002-atomic-reactive-updates.md index a061585..336c675 100644 --- a/docs/adr/ADR-002-atomic-reactive-updates.md +++ b/website/adr/ADR-002-atomic-reactive-updates.md @@ -38,7 +38,7 @@ When a configuration file changes that affects **both** types, subscribers need ### Why Standard Patterns Fail -**Microsoft's IOptionsMonitor:** +**Microsoft's IOptionsMonitor<T>:** ```csharp services.Configure(config.GetSection("App")); diff --git a/docs/adr/ADR-003-provider-consistency-empty-objects.md b/website/adr/ADR-003-provider-consistency-empty-objects.md similarity index 100% rename from docs/adr/ADR-003-provider-consistency-empty-objects.md rename to website/adr/ADR-003-provider-consistency-empty-objects.md diff --git a/docs/adr/ADR-004-aggregate-rules.md b/website/adr/ADR-004-aggregate-rules.md similarity index 100% rename from docs/adr/ADR-004-aggregate-rules.md rename to website/adr/ADR-004-aggregate-rules.md diff --git a/docs/adr/ADR-005-multi-tenant-configuration.md b/website/adr/ADR-005-multi-tenant-configuration.md similarity index 100% rename from docs/adr/ADR-005-multi-tenant-configuration.md rename to website/adr/ADR-005-multi-tenant-configuration.md diff --git a/docs/adr/ADR-006-di-aware-configuration.md b/website/adr/ADR-006-di-aware-configuration.md similarity index 100% rename from docs/adr/ADR-006-di-aware-configuration.md rename to website/adr/ADR-006-di-aware-configuration.md diff --git a/website/adr/index.md b/website/adr/index.md new file mode 100644 index 0000000..1ad4b8b --- /dev/null +++ b/website/adr/index.md @@ -0,0 +1,12 @@ +# Architecture Decision Records + +These ADRs capture the **why** behind Cocoar.Configuration's key design choices — the context, the decision, the trade-offs, and the alternatives that were rejected. They are decision *records* (point-in-time rationale and history), not how-to guides; for usage, start with the [Guide](/guide/getting-started). + +| ADR | Decision | +|---|---| +| [ADR-001](/adr/ADR-001-capabilities-system) | Capabilities system for cross-assembly extensibility | +| [ADR-002](/adr/ADR-002-atomic-reactive-updates) | Atomic reactive configuration updates (tuple semantics) | +| [ADR-003](/adr/ADR-003-provider-consistency-empty-objects) | Provider consistency — empty objects on failure | +| [ADR-004](/adr/ADR-004-aggregate-rules) | Aggregate rules with an isolated execution boundary | +| [ADR-005](/adr/ADR-005-multi-tenant-configuration) | Multi-tenant configuration — per-tenant pipelines on a shared global base | +| [ADR-006](/adr/ADR-006-di-aware-configuration) | DI-aware (service-backed) two-layer configuration | diff --git a/website/guide/di/service-backed.md b/website/guide/di/service-backed.md index e892a58..d677438 100644 --- a/website/guide/di/service-backed.md +++ b/website/guide/di/service-backed.md @@ -2,7 +2,7 @@ Some configuration sources need a service from your application container *to load* — an `IHttpClientFactory`, a Marten `IDocumentStore`, an EF `IDbContextFactory`. But `AddCocoarConfiguration` runs **before** `BuildServiceProvider()`, so those services don't exist yet. This is a hard boundary in every framework: config that needs the container can't also *bootstrap* the container. -Cocoar solves it the same way Microsoft splits `IConfiguration` (eager, dumb sources) from `IOptions` (lazy, DI-bound) — with a **two-layer model**, in Cocoar's own ordered-layer idiom (see [ADR-006](https://github.com/cocoar-dev/cocoar.configuration/blob/develop/docs/adr/ADR-006-di-aware-configuration.md)). +Cocoar solves it the same way Microsoft splits `IConfiguration` (eager, dumb sources) from `IOptions` (lazy, DI-bound) — with a **two-layer model**, in Cocoar's own ordered-layer idiom (see [ADR-006](/adr/ADR-006-di-aware-configuration)). | Layer | Method | When | `IServiceProvider`? | |---|---|---|---| @@ -162,4 +162,4 @@ Because these overloads target `ServiceBackedProviderBuilder`, using them ins - [Multi-Tenancy](/guide/multi-tenancy/overview) — `.TenantScoped()` and consuming a tenant's config (`ITenantReactiveConfig`) - [ASP.NET Core](/guide/di/aspnetcore) -- [ADR-006](https://github.com/cocoar-dev/cocoar.configuration/blob/develop/docs/adr/ADR-006-di-aware-configuration.md) — the design rationale +- [ADR-006](/adr/ADR-006-di-aware-configuration) — the design rationale diff --git a/website/guide/multi-tenancy/overview.md b/website/guide/multi-tenancy/overview.md index 8e8de1e..4b9ad68 100644 --- a/website/guide/multi-tenancy/overview.md +++ b/website/guide/multi-tenancy/overview.md @@ -2,7 +2,7 @@ Multi-tenant applications need the **same configuration type to resolve to different values per tenant** — a global default for everything, with each tenant overriding only the keys it sets and inheriting the rest. -Cocoar.Configuration models this as **per-tenant pipeline bundles layered on a shared global base** (see [ADR-005](https://github.com/cocoar-dev/cocoar.configuration/blob/develop/docs/adr/ADR-005-multi-tenant-configuration.md)). You author **one flat rule list** and mark the per-tenant rules with `.TenantScoped()`; the tenant id flows in through the configuration accessor. There is no second authoring surface and no provider becomes "tenant-aware". +Cocoar.Configuration models this as **per-tenant pipeline bundles layered on a shared global base** (see [ADR-005](/adr/ADR-005-multi-tenant-configuration)). You author **one flat rule list** and mark the per-tenant rules with `.TenantScoped()`; the tenant id flows in through the configuration accessor. There is no second authoring surface and no provider becomes "tenant-aware". ::: tip When do I need this? Only when one process serves many tenants and the **same type** must differ per tenant at runtime, with tenants added/removed dynamically. A single-tenant app needs none of this — the global pipeline is unchanged. diff --git a/website/guide/providers/custom.md b/website/guide/providers/custom.md index e41027a..d9001af 100644 --- a/website/guide/providers/custom.md +++ b/website/guide/providers/custom.md @@ -192,7 +192,7 @@ services.AddCocoarConfiguration(c => c The Layer-2 overload targets `ServiceBackedProviderBuilder`. Calling it inside the Layer-1 `UseConfiguration` (a plain `TypedRuleBuilder`) is a **compile error** — the type system keeps DI-backed loading out of the eager layer. The whole seam (`ServiceBackedProviderBuilder.Context`, `ServiceBackedRuleContext`, `WithActivationGate`) is **public**, so this needs no internals. ::: -Lifetime discipline (ADR-006 §9): the `IServiceProvider` is the **root** — resolve singletons / factories (`IDbContextFactory`, `IDocumentStore`, `IHttpClientFactory`) and open a short-lived unit per read (as the `await using var conn` above does). Never resolve a scoped service from root. Combine with `.TenantScoped()` for DB-config-per-tenant. See [Service-Backed Configuration](/guide/di/service-backed) for the full lifecycle, readiness, and failure contracts. +Lifetime discipline ([ADR-006](/adr/ADR-006-di-aware-configuration) §9): the `IServiceProvider` is the **root** — resolve singletons / factories (`IDbContextFactory`, `IDocumentStore`, `IHttpClientFactory`) and open a short-lived unit per read (as the `await using var conn` above does). Never resolve a scoped service from root. Combine with `.TenantScoped()` for DB-config-per-tenant. See [Service-Backed Configuration](/guide/di/service-backed) for the full lifecycle, readiness, and failure contracts. ## Change Detection From 776321d4883faec704165a30cb959fc1a137fa8c Mon Sep 17 00:00:00 2001 From: Bernhard Windisch Date: Sun, 31 May 2026 09:13:06 +0200 Subject: [PATCH 07/18] =?UTF-8?q?chore:=20update=20Examples=20README=20?= =?UTF-8?q?=E2=80=94=20all=2019=20examples=20listed,=20solution=20file=20r?= =?UTF-8?q?eferences=20removed?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- .../Tenant/ITenantContext.cs | 13 -- .../Tenant/ITenantReactiveConfig.cs | 17 --- .../Tenant/TenantReactiveConfig.cs | 39 ------ .../Tenant/TenantReactiveConfigExtensions.cs | 54 -------- src/Examples/README.md | 116 ++++++++++-------- 5 files changed, 66 insertions(+), 173 deletions(-) delete mode 100644 src/Cocoar.Configuration.AspNetCore/Tenant/ITenantContext.cs delete mode 100644 src/Cocoar.Configuration.AspNetCore/Tenant/ITenantReactiveConfig.cs delete mode 100644 src/Cocoar.Configuration.AspNetCore/Tenant/TenantReactiveConfig.cs delete mode 100644 src/Cocoar.Configuration.AspNetCore/Tenant/TenantReactiveConfigExtensions.cs diff --git a/src/Cocoar.Configuration.AspNetCore/Tenant/ITenantContext.cs b/src/Cocoar.Configuration.AspNetCore/Tenant/ITenantContext.cs deleted file mode 100644 index be76f45..0000000 --- a/src/Cocoar.Configuration.AspNetCore/Tenant/ITenantContext.cs +++ /dev/null @@ -1,13 +0,0 @@ -namespace Cocoar.Configuration.AspNetCore; - -/// -/// Supplies the current request's tenant id for scoped, per-request configuration consumption (ADR-006 §11). -/// The application implements this — reading a claim, header, or route value; only the app knows where the -/// tenant lives — and registers it as scoped, or relies on the default registered by -/// AddCocoarTenantReactiveConfig(resolver). -/// -public interface ITenantContext -{ - /// The current tenant id, or null when none is resolved for this request. - string? Current { get; } -} diff --git a/src/Cocoar.Configuration.AspNetCore/Tenant/ITenantReactiveConfig.cs b/src/Cocoar.Configuration.AspNetCore/Tenant/ITenantReactiveConfig.cs deleted file mode 100644 index 5257bd6..0000000 --- a/src/Cocoar.Configuration.AspNetCore/Tenant/ITenantReactiveConfig.cs +++ /dev/null @@ -1,17 +0,0 @@ -using Cocoar.Configuration.Reactive; - -namespace Cocoar.Configuration.AspNetCore; - -/// -/// A scoped reactive configuration view bound to the current request's tenant (from -/// ) — ADR-006 §11. Inject this into scoped/transient consumers to get THIS -/// tenant's effective configuration; it delegates to ConfigManager.GetReactiveConfigForTenant<T>(tenant). -/// -/// A singleton can never have an ambient tenant — it must call GetReactiveConfigForTenant<T>(id) -/// explicitly. This is a distinct interface from (which stays the global, -/// singleton view), so injecting one never breaks the other (the ADR-006 §11 trap). -/// -/// -public interface ITenantReactiveConfig : IReactiveConfig -{ -} diff --git a/src/Cocoar.Configuration.AspNetCore/Tenant/TenantReactiveConfig.cs b/src/Cocoar.Configuration.AspNetCore/Tenant/TenantReactiveConfig.cs deleted file mode 100644 index 74be5c0..0000000 --- a/src/Cocoar.Configuration.AspNetCore/Tenant/TenantReactiveConfig.cs +++ /dev/null @@ -1,39 +0,0 @@ -using Cocoar.Configuration.Core; -using Cocoar.Configuration.Reactive; - -namespace Cocoar.Configuration.AspNetCore; - -/// -/// Scoped adapter that binds to the current request's tenant -/// (from ) and delegates to ConfigManager.GetReactiveConfigForTenant<T>. -/// -internal sealed class TenantReactiveConfig : ITenantReactiveConfig -{ - private readonly Lazy> _inner; - - public TenantReactiveConfig(ConfigManager configManager, ITenantContext tenantContext) - { - ArgumentNullException.ThrowIfNull(configManager); - ArgumentNullException.ThrowIfNull(tenantContext); - - // Resolved lazily on first use: the tenant pipeline must already be initialized (e.g. via - // EnsureTenantInitializedAsync in middleware). Binding to ITenantContext.Current here ties this scoped - // view to THIS request's tenant. - _inner = new Lazy>(() => - { - var tenant = tenantContext.Current; - if (string.IsNullOrWhiteSpace(tenant)) - { - throw new InvalidOperationException( - "No tenant resolved in ITenantContext for the current request. ITenantReactiveConfig " + - "requires a tenant; a singleton must use ConfigManager.GetReactiveConfigForTenant(id) explicitly."); - } - - return configManager.GetReactiveConfigForTenant(tenant); - }); - } - - public T CurrentValue => _inner.Value.CurrentValue; - - public IDisposable Subscribe(IObserver observer) => _inner.Value.Subscribe(observer); -} diff --git a/src/Cocoar.Configuration.AspNetCore/Tenant/TenantReactiveConfigExtensions.cs b/src/Cocoar.Configuration.AspNetCore/Tenant/TenantReactiveConfigExtensions.cs deleted file mode 100644 index 7febb83..0000000 --- a/src/Cocoar.Configuration.AspNetCore/Tenant/TenantReactiveConfigExtensions.cs +++ /dev/null @@ -1,54 +0,0 @@ -using Microsoft.AspNetCore.Http; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.DependencyInjection.Extensions; - -namespace Cocoar.Configuration.AspNetCore; - -/// -/// Registration for the scoped per-request tenant configuration adapter (ADR-006 §11). -/// -public static class TenantReactiveConfigExtensions -{ - /// - /// Registers the scoped adapter so scoped/transient consumers can - /// inject the current request's tenant configuration. Leaves the singleton IReactiveConfig<T> - /// registration untouched (the §11 trap). - /// - /// The application must also register a scoped — either its own, or the default - /// registered here when is supplied. Tenants must be initialized - /// (e.g. EnsureTenantInitializedAsync in middleware) before the adapter is used in a request. - /// - /// - /// The service collection. - /// Optional resolver of the tenant id from the current - /// (a claim, header, or route value). When provided, a default scoped and - /// are registered. - public static IServiceCollection AddCocoarTenantReactiveConfig( - this IServiceCollection services, - Func? tenantResolver = null) - { - ArgumentNullException.ThrowIfNull(services); - - services.TryAddScoped(typeof(ITenantReactiveConfig<>), typeof(TenantReactiveConfig<>)); - - if (tenantResolver is not null) - { - services.AddHttpContextAccessor(); - services.TryAddScoped(sp => - { - var httpContext = sp.GetRequiredService().HttpContext; - return new DelegateTenantContext(httpContext is null ? null : tenantResolver(httpContext)); - }); - } - - return services; - } -} - -/// An carrying a pre-resolved tenant id (used by the default resolver path). -internal sealed class DelegateTenantContext : ITenantContext -{ - public DelegateTenantContext(string? current) => Current = current; - - public string? Current { get; } -} diff --git a/src/Examples/README.md b/src/Examples/README.md index 3d7458e..bbebf51 100644 --- a/src/Examples/README.md +++ b/src/Examples/README.md @@ -1,50 +1,66 @@ -# Examples - -This directory contains runnable examples for **Cocoar.Configuration**. Each subfolder is an independent project you can open, run, and modify. - -## Projects - -- **BasicUsage** – Common ASP.NET Core pattern with file + environment overrides -- **FileLayering** – Multiple JSON file layering (base + env + local) -- **DynamicDependencies** – Later rules derive values from earlier configurations -- **ConditionalRulesExample** – Using `When()` with config-aware predicates for conditional rule execution -- **AspNetCoreExample** – Minimal API exposing configuration via endpoints -- **GenericProviderAPI** – Using the generic provider registration API -- **HttpPollingExample** – Demonstrates pattern for remote/polling configuration -- **MicrosoftAdapterExample** – Integrating `IConfigurationSource` providers -- **StaticProviderExample** – Static seeding with JSON strings and factory functions -- **SimplifiedCoreExample** – Pure core library usage without DI (ConfigManager only) -- **ExposeExample** – Interface exposure without DI frameworks -- **TupleReactiveExample** – Tuple-based reactive multi-config snapshot & aligned emission demo - -## Running an Example -```pwsh -cd src/Examples/BasicUsage -dotnet run -``` - -Some examples expect local JSON like `config.json`, `base.json`, etc. Copy the inline examples from the source `Program.cs` comments. - -## Switching to PackageReference -Currently examples use `ProjectReference` to always reflect the latest API. To test against a published package: -1. Remove the `` entries -2. Add: -```xml - - - -``` -(And any adapter/provider packages you need.) - -## CI / Build -Examples are excluded from packing. Build manually if needed: -```pwsh -dotnet build src/Examples/Examples.sln -c Release -``` - -## Adding a New Example -1. Create `src/Examples//.csproj` (copy one of the existing ones) -2. Add `Program.cs` with top-level statements -3. Add the project to `Examples.sln` (or let someone run `dotnet sln add` later) - -Feel free to keep examples minimal—prefer a single `Program.cs` unless scenario requires more. +# Examples + +This directory contains runnable examples for **Cocoar.Configuration**. Each subfolder is an independent project you can open, run, and modify. + +## Projects + +### Core & providers +- **SimplifiedCoreExample** – Pure core library usage without DI (`ConfigManager` only) +- **BasicUsage** – Common ASP.NET Core pattern with file + environment overrides +- **FileLayering** – Multiple JSON file layering (base + env + local) +- **StaticProviderExample** – Static seeding with JSON strings and factory functions +- **CommandLineExample** – Command-line argument provider with configurable prefixes +- **HttpPollingExample** – Remote/polling configuration pattern +- **MicrosoftAdapterExample** – Bridging existing `IConfiguration`/`IConfigurationSource` providers +- **GenericProviderAPI** – Using the generic provider registration API + +### Rules, dependencies & reactivity +- **ConditionalRulesExample** – `When()` with config-aware predicates for conditional rule execution +- **DynamicDependencies** – Later rules derive values from earlier configurations +- **AggregateRules** – Composable rule grouping (`FromFiles` sugar) with byte-level JSON merge +- **TupleReactiveExample** – Tuple-based reactive multi-config snapshot & aligned emission demo + +### DI & ASP.NET Core +- **ExposeExample** – Interface exposure without DI frameworks +- **AspNetCoreExample** – Minimal API exposing configuration via endpoints +- **ServiceBackedConfig** – DI-aware service-backed configuration (ADR-006): eager Layer-1 + lazy `IServiceProvider`-gated Layer-2 that activates on host start via a recompute +- **TestingOverridesExample** – Test isolation with `CocoarTestConfiguration` (`Replace`/`Append` overrides) + +### Writable store & secrets +- **WritableStoreExample** – Writable sparse-overlay store (`FromStore`): set/reset/clear plus `DescribeAsync` provenance +- **SecretsBasicExample** – Basic `Secret` usage with a self-signed certificate +- **SecretsCertificateExample** – Production-ready secrets with certificate-from-file decryption of pre-encrypted values + +## Running an Example +```pwsh +cd src/Examples/BasicUsage +dotnet run +``` + +Some examples expect local JSON like `config.json`, `base.json`, etc. Copy the inline examples from the source `Program.cs` comments. + +## Switching to PackageReference +Currently examples use `ProjectReference` to always reflect the latest API. To test against a published package: +1. Remove the `` entries +2. Add: +```xml + + + +``` +(And any adapter/provider packages you need.) + +## Building +Examples are excluded from packing. The build is directory-based — there is no solution file. Build them along with the rest of the source tree: +```pwsh +dotnet build ./src -c Release +``` + +## Adding a New Example +1. Create `src/Examples//.csproj` (copy one of the existing ones) +2. Add `Program.cs` with top-level statements (or a `Main`) +3. Add it to the list above + +The directory-based build picks up the new project automatically — no solution file to update. + +Feel free to keep examples minimal—prefer a single `Program.cs` unless the scenario requires more. From bea89096619c765bae3c17a239ef8448c6d53f9e Mon Sep 17 00:00:00 2001 From: Bernhard Windisch Date: Sun, 31 May 2026 09:13:29 +0200 Subject: [PATCH 08/18] feat: per-tenant secret encryption-key publishing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit kid=tenant model: each tenant gets exactly one public key (newest cert in its folder). FolderKeyInfoProvider resolves the per-tenant subfolder; SpkiEncoding produces the base64url-encoded SubjectPublicKeyInfo for the wire format. ISecretEncryptionKeyProvider exposes GetCurrentKey() (single- tenant) and GetCurrentKeyForTenant(tenantId) (multi-tenant); list/by-kid overloads removed. MapTenantSecretEncryptionKey() endpoint resolves the tenant from ITenantContext — one key per request, no cross-tenant exposure. Co-Authored-By: Claude Sonnet 4.6 --- .../SecretEncryptionKey.cs | 33 ++- .../SecretEncryptionKeyEndpointExtensions.cs | 99 ++++----- .../Core/ISecretEncryptionKeyInfoProvider.cs | 20 +- .../Core/SecretEncryptionKeyProvider.cs | 48 ++--- .../Protectors/Hybrid/CertificateInventory.cs | 28 +++ .../Hybrid/FolderKeyInfoProvider.cs | 37 ++++ .../Hybrid/HybridProtectorRegistrar.cs | 17 +- .../Hybrid/InventoryKeyInfoProvider.cs | 6 +- .../Secrets/Protectors/Hybrid/SpkiEncoding.cs | 11 + .../SecretEncryptionKeyEndpointTests.cs | 194 ++++++++++++------ .../TenantSecretEncryptionKeyTests.cs | 171 +++++++++++++++ .../SecretEncryptionKeyProviderTests.cs | 23 ++- website/guide/secrets/key-publishing.md | 51 +++-- 13 files changed, 520 insertions(+), 218 deletions(-) create mode 100644 src/Cocoar.Configuration/Secrets/Protectors/Hybrid/FolderKeyInfoProvider.cs create mode 100644 src/Cocoar.Configuration/Secrets/Protectors/Hybrid/SpkiEncoding.cs create mode 100644 src/tests/Cocoar.Configuration.MultiTenant.Tests/TenantSecretEncryptionKeyTests.cs diff --git a/src/Cocoar.Configuration.Abstractions/SecretEncryptionKey.cs b/src/Cocoar.Configuration.Abstractions/SecretEncryptionKey.cs index 6238871..f361da9 100644 --- a/src/Cocoar.Configuration.Abstractions/SecretEncryptionKey.cs +++ b/src/Cocoar.Configuration.Abstractions/SecretEncryptionKey.cs @@ -55,37 +55,28 @@ public sealed record SecretEncryptionPublicKey } /// -/// The published set of current encryption public keys (one per kid) — the wire shape of the list -/// endpoint: { "keys": [ ... ] }. The keys field name is pinned via -/// so a host JSON naming policy cannot rename it. -/// -public sealed record SecretEncryptionKeySet -{ - /// The current encryption public key for each configured kid. Never null; may be empty. - [JsonPropertyName("keys")] - public IReadOnlyList Keys { get; init; } = Array.Empty(); -} - -/// -/// Publishes the public half of the configured secrets encryption key(s) so external producers can +/// Publishes the public half of the configured secrets encryption key so external producers can /// build cocoar.secret envelopes the server can later decrypt. Resolved from dependency injection. /// -/// There is exactly ONE current key per kid — the certificate the decryption engine prefers. +/// There is exactly ONE current key per tenant — the newest certificate (per the configured comparer). /// Implementations re-read key material on every call so certificate rotation is reflected. Public keys -/// are safe to expose; no private key or plaintext is ever reachable through this API. +/// are safe to expose; no private key or plaintext is ever reachable through this API. Each accessor +/// returns a SINGLE key — never a list — so one tenant's key can never expose another's. /// /// public interface ISecretEncryptionKeyProvider { /// - /// Returns the current encryption public key for each configured kid (one per kid). Returns an - /// empty list when nothing is publishable; never throws for the "no keys" case. + /// The current encryption public key for a single-tenant deployment (one configured kid), or + /// when nothing is publishable. For multi-tenant deployments use + /// instead. /// - IReadOnlyList GetCurrentKeys(); + SecretEncryptionPublicKey? GetCurrentKey(); /// - /// The current encryption public key for , or if that - /// kid is not currently published. + /// The current encryption public key for (one per tenant), or + /// if that tenant has no publishable key. Returns exactly one key, so it + /// can never expose another tenant's key. /// - SecretEncryptionPublicKey? GetCurrentKey(string kid); + SecretEncryptionPublicKey? GetCurrentKeyForTenant(string tenantId); } diff --git a/src/Cocoar.Configuration.AspNetCore/SecretEncryptionKeyEndpointExtensions.cs b/src/Cocoar.Configuration.AspNetCore/SecretEncryptionKeyEndpointExtensions.cs index 62ba956..622d776 100644 --- a/src/Cocoar.Configuration.AspNetCore/SecretEncryptionKeyEndpointExtensions.cs +++ b/src/Cocoar.Configuration.AspNetCore/SecretEncryptionKeyEndpointExtensions.cs @@ -1,3 +1,4 @@ +using Cocoar.Configuration.DI; using Cocoar.Configuration.Secrets.SecretTypes; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; @@ -7,83 +8,65 @@ namespace Cocoar.Configuration.AspNetCore; /// -/// Extension methods for publishing the configured secrets encryption public key(s) so external +/// Extension methods for publishing the configured secrets encryption public key so external /// producers (e.g. a browser library) can build cocoar.secret envelopes the server decrypts. -/// Only public-key material is exposed — never a private key or plaintext. +/// Only public-key material is exposed — never a private key or plaintext. Each endpoint returns +/// exactly ONE key (never a list), so one tenant's key can never expose another's. /// public static class SecretEncryptionKeyEndpointExtensions { + private const string DefaultPattern = "/.well-known/cocoar/encryption-key"; + /// - /// Maps a GET endpoint that lists the current encryption public key per configured kid as - /// { "keys": [ ... ] }. Always returns 200 (an empty list when no key is publishable, e.g. - /// no secrets configured). Returns an so callers can chain - /// .RequireAuthorization(). Not secured by default (matches MapFeatureFlagEndpoints). + /// Maps a GET endpoint returning the current encryption public key for a SINGLE-TENANT deployment, + /// or 404 ProblemDetails when none is published. Returns an + /// so callers can chain .RequireAuthorization(). Not secured by default (matches + /// MapFeatureFlagEndpoints). /// - public static IEndpointConventionBuilder MapSecretEncryptionKeys( + public static IEndpointConventionBuilder MapSecretEncryptionKey( this IEndpointRouteBuilder endpoints, - string pattern = "/.well-known/cocoar/encryption-keys") + string pattern = DefaultPattern) { return endpoints.MapGet(pattern, (IServiceProvider sp) => { - var provider = sp.GetService(); - var keys = provider?.GetCurrentKeys() ?? Array.Empty(); - return Results.Json(new SecretEncryptionKeySet { Keys = keys }); + var key = sp.GetService()?.GetCurrentKey(); + return key is null ? KeyNotFound() : Results.Json(key); }); } /// - /// Maps a GET endpoint that returns the current encryption public key for a specific kid, or a - /// 404 ProblemDetails when that kid is not currently published. Returns an - /// for chaining .RequireAuthorization(). + /// Maps a GET endpoint returning the current encryption public key for the tenant the request + /// already resolves to. The tenant is read from (auth, subdomain, + /// or route — supplied by the app, never a client-chosen value), and ONLY that tenant's single + /// current key is returned (404 ProblemDetails when none, 400 when no tenant is resolved). Returns + /// an for chaining .RequireAuthorization(). + /// The app must register a scoped — e.g. via + /// AddCocoarTenantResolver<TService>(...) or its own implementation. /// - public static IEndpointConventionBuilder MapSecretEncryptionKeyByKid( + public static IEndpointConventionBuilder MapTenantSecretEncryptionKey( this IEndpointRouteBuilder endpoints, - string pattern = "/.well-known/cocoar/encryption-keys/{kid}") + string pattern = DefaultPattern) { - return endpoints.MapGet(pattern, (string kid, IServiceProvider sp) => + return endpoints.MapGet(pattern, (HttpContext http) => { - var provider = sp.GetService(); - var key = provider?.GetCurrentKey(kid); - return key is null - ? Results.Problem( - detail: $"No published encryption key for kid '{kid}'.", - title: "Encryption key not found", - statusCode: StatusCodes.Status404NotFound) - : Results.Json(key); - }); - } + var sp = http.RequestServices; + var tenant = sp.GetService()?.Current; + if (string.IsNullOrWhiteSpace(tenant)) + { + return Results.Problem( + detail: "No tenant is resolved for this request.", + title: "Tenant not resolved", + statusCode: StatusCodes.Status400BadRequest); + } - /// - /// Maps both the list endpoint (at ) and the by-kid endpoint - /// (at {basePattern}/{kid}), returning a composite - /// so a single .RequireAuthorization() (or other convention) applies to both routes. - /// - public static IEndpointConventionBuilder MapSecretEncryptionKeyEndpoints( - this IEndpointRouteBuilder endpoints, - string basePattern = "/.well-known/cocoar/encryption-keys") - { - var list = endpoints.MapSecretEncryptionKeys(basePattern); - var byKid = endpoints.MapSecretEncryptionKeyByKid($"{basePattern.TrimEnd('/')}/{{kid}}"); - return new CompositeEndpointConventionBuilder(list, byKid); + var key = sp.GetService()?.GetCurrentKeyForTenant(tenant); + return key is null ? KeyNotFound() : Results.Json(key); + }); } - private sealed class CompositeEndpointConventionBuilder : IEndpointConventionBuilder - { - private readonly IEndpointConventionBuilder[] _builders; - - public CompositeEndpointConventionBuilder(params IEndpointConventionBuilder[] builders) - => _builders = builders; - - public void Add(Action convention) - { - foreach (var builder in _builders) - builder.Add(convention); - } - - public void Finally(Action finallyConvention) - { - foreach (var builder in _builders) - builder.Finally(finallyConvention); - } - } + private static IResult KeyNotFound() + => Results.Problem( + detail: "No encryption key is currently published.", + title: "Encryption key not found", + statusCode: StatusCodes.Status404NotFound); } diff --git a/src/Cocoar.Configuration/Secrets/Core/ISecretEncryptionKeyInfoProvider.cs b/src/Cocoar.Configuration/Secrets/Core/ISecretEncryptionKeyInfoProvider.cs index 56fea7c..c4930d4 100644 --- a/src/Cocoar.Configuration/Secrets/Core/ISecretEncryptionKeyInfoProvider.cs +++ b/src/Cocoar.Configuration/Secrets/Core/ISecretEncryptionKeyInfoProvider.cs @@ -3,12 +3,24 @@ namespace Cocoar.Configuration.Secrets.Core; /// -/// Internal capability composed beside a secrets protector. Exposes the current encryption public key -/// for the protector's kid, re-read from the underlying certificate source on every call so rotation -/// is reflected. Composed only where a single publishable encryption kid is unambiguous. +/// Internal capability composed beside a secrets protector. Exposes the current encryption public key, +/// re-read from the underlying certificate source on every call so rotation is reflected. Single-kid +/// mode composes one bound to that kid; folder / multi-tenant mode composes one that resolves the +/// preferred public key per kid (= per tenant) on demand. /// internal interface ISecretEncryptionKeyInfoProvider { - /// The current encryption public key, or when none is available. + /// + /// The current encryption public key for this provider's single, unambiguous kid, or + /// when none is available or this provider serves multiple kids + /// (folder / multi-tenant mode). + /// SecretEncryptionPublicKey? TryGetCurrentKey(); + + /// + /// The current encryption public key for the given (in folder / + /// multi-tenant mode kid == tenant id), or if this provider does + /// not serve that kid. + /// + SecretEncryptionPublicKey? TryGetKeyForKid(string kid); } diff --git a/src/Cocoar.Configuration/Secrets/Core/SecretEncryptionKeyProvider.cs b/src/Cocoar.Configuration/Secrets/Core/SecretEncryptionKeyProvider.cs index 645c08d..e09eee9 100644 --- a/src/Cocoar.Configuration/Secrets/Core/SecretEncryptionKeyProvider.cs +++ b/src/Cocoar.Configuration/Secrets/Core/SecretEncryptionKeyProvider.cs @@ -6,7 +6,8 @@ namespace Cocoar.Configuration.Secrets.Core; /// /// Public-facing . Resolves the composed /// capabilities lazily on every call — so certificate -/// rotation is reflected and no stale snapshot is held — and aggregates one current key per kid. +/// rotation is reflected and no stale snapshot is held — and returns exactly one current public key +/// (the single-tenant key, or the requested tenant's key; never a list). /// internal sealed class SecretEncryptionKeyProvider : ISecretEncryptionKeyProvider { @@ -15,46 +16,39 @@ internal sealed class SecretEncryptionKeyProvider : ISecretEncryptionKeyProvider public SecretEncryptionKeyProvider(ConfigManagerCapabilityScope scope) => _scope = scope ?? throw new ArgumentNullException(nameof(scope)); - public IReadOnlyList GetCurrentKeys() + public SecretEncryptionPublicKey? GetCurrentKey() { var composition = _scope.Owner.GetComposition(); var infoProviders = composition?.GetAll(); - if (infoProviders is null || infoProviders.Count == 0) - return Array.Empty(); - - // One key per kid. On a kid collision the VALUE is last-writer-wins (matching the decrypt - // resolver's recency preference); the emitted list POSITION is first-appearance. Collisions - // cannot occur in the current single-kid publishing path — revisit ordering for multi-kid. - var byKid = new Dictionary(StringComparer.Ordinal); - var order = new List(); + if (infoProviders is null) + return null; + foreach (var info in infoProviders) { var key = info.TryGetCurrentKey(); - if (key is null) - continue; - - if (!byKid.ContainsKey(key.Kid)) - order.Add(key.Kid); - byKid[key.Kid] = key; + if (key is not null) + return key; } - if (order.Count == 0) - return Array.Empty(); - - var result = new List(order.Count); - foreach (var kid in order) - result.Add(byKid[kid]); - return result; + return null; } - public SecretEncryptionPublicKey? GetCurrentKey(string kid) + public SecretEncryptionPublicKey? GetCurrentKeyForTenant(string tenantId) { - if (string.IsNullOrEmpty(kid)) + if (string.IsNullOrWhiteSpace(tenantId)) return null; - foreach (var key in GetCurrentKeys()) + var composition = _scope.Owner.GetComposition(); + var infoProviders = composition?.GetAll(); + if (infoProviders is null) + return null; + + // Exactly one tenant is queried; return that tenant's single current key and never + // enumerate or expose any other tenant's key. + foreach (var info in infoProviders) { - if (string.Equals(key.Kid, kid, StringComparison.Ordinal)) + var key = info.TryGetKeyForKid(tenantId); + if (key is not null) return key; } diff --git a/src/Cocoar.Configuration/Secrets/Protectors/Hybrid/CertificateInventory.cs b/src/Cocoar.Configuration/Secrets/Protectors/Hybrid/CertificateInventory.cs index 7d6810b..181f02e 100644 --- a/src/Cocoar.Configuration/Secrets/Protectors/Hybrid/CertificateInventory.cs +++ b/src/Cocoar.Configuration/Secrets/Protectors/Hybrid/CertificateInventory.cs @@ -210,12 +210,32 @@ public bool TryDecryptWithKid(HybridEnvelope envelope, string kid, out byte[] pl /// because mutates the cache. /// internal byte[]? TryExportPreferredPublicKey() + => TryExportPreferredPublicKey(kidPath: null, kidPathWithSep: null); + + /// + /// Like but restricted to the certificates physically + /// under the {folder}/{kid} subfolder — the per-tenant (kid = tenant) publishing path. The + /// preferred (first-ordered, i.e. newest per the configured comparer) cert in that subfolder is + /// exported; older certs in the same subfolder remain available for decryption only. Returns + /// when that kid has no usable RSA certificate. + /// + internal byte[]? TryExportPreferredPublicKey(string kid) + { + ArgumentException.ThrowIfNullOrWhiteSpace(kid); + var kidPath = Path.GetFullPath(Path.Combine(_folderPath, kid)); + return TryExportPreferredPublicKey(kidPath, kidPath + Path.DirectorySeparatorChar); + } + + private byte[]? TryExportPreferredPublicKey(string? kidPath, string? kidPathWithSep) { _lock.EnterWriteLock(); try { foreach (var certPath in _sortedCertPaths) { + if (kidPath is not null && !IsCertUnderKid(certPath, kidPath, kidPathWithSep!)) + continue; + X509Certificate2 cert; try { @@ -246,6 +266,14 @@ ex is CryptographicException or IOException or UnauthorizedAccessException } } + private static bool IsCertUnderKid(string certPath, string kidPath, string kidPathWithSep) + { + var certDir = Path.GetDirectoryName(certPath); + return certDir != null + && (certPath.StartsWith(kidPathWithSep, StringComparison.OrdinalIgnoreCase) + || string.Equals(certDir, kidPath, StringComparison.OrdinalIgnoreCase)); + } + private bool TryDecryptWithCert(string certPath, HybridEnvelope envelope, out byte[] plaintext) { try diff --git a/src/Cocoar.Configuration/Secrets/Protectors/Hybrid/FolderKeyInfoProvider.cs b/src/Cocoar.Configuration/Secrets/Protectors/Hybrid/FolderKeyInfoProvider.cs new file mode 100644 index 0000000..87f2df4 --- /dev/null +++ b/src/Cocoar.Configuration/Secrets/Protectors/Hybrid/FolderKeyInfoProvider.cs @@ -0,0 +1,37 @@ +using Cocoar.Configuration.Secrets.Core; +using Cocoar.Configuration.Secrets.SecretTypes; + +namespace Cocoar.Configuration.Secrets.Protectors.Hybrid; + +/// +/// Publishes the current encryption public key per kid for folder / multi-tenant mode, where each kid +/// is a subfolder (kid = tenant). Resolves the preferred (newest per the configured comparer) cert in +/// the requested kid's subfolder on demand, so rotation and tenant add/remove are reflected live. +/// There is no single "current" key — callers ask per kid (= per tenant). +/// +internal sealed class FolderKeyInfoProvider : ISecretEncryptionKeyInfoProvider +{ + private readonly CertificateInventory _inventory; + + public FolderKeyInfoProvider(CertificateInventory inventory) + => _inventory = inventory ?? throw new ArgumentNullException(nameof(inventory)); + + // Folder mode serves many kids; there is no single unambiguous "current" kid to publish. + public SecretEncryptionPublicKey? TryGetCurrentKey() => null; + + public SecretEncryptionPublicKey? TryGetKeyForKid(string kid) + { + if (string.IsNullOrWhiteSpace(kid)) + return null; + + var spki = _inventory.TryExportPreferredPublicKey(kid); + if (spki is null) + return null; + + return new SecretEncryptionPublicKey + { + Kid = kid, + PublicKey = SpkiEncoding.ToBase64Url(spki), + }; + } +} diff --git a/src/Cocoar.Configuration/Secrets/Protectors/Hybrid/HybridProtectorRegistrar.cs b/src/Cocoar.Configuration/Secrets/Protectors/Hybrid/HybridProtectorRegistrar.cs index 476690c..66c7684 100644 --- a/src/Cocoar.Configuration/Secrets/Protectors/Hybrid/HybridProtectorRegistrar.cs +++ b/src/Cocoar.Configuration/Secrets/Protectors/Hybrid/HybridProtectorRegistrar.cs @@ -18,16 +18,6 @@ internal sealed class HybridProtectorConfigurator(ConfigManagerCapabilityScope c { private readonly ConfigManagerCapabilityScope _capabilityScope = capabilityScope ?? throw new ArgumentNullException(nameof(capabilityScope)); - private void RegisterProtector(IRuntimeSecretDecryptor protector) - { - var composition = _capabilityScope.Owner.GetComposition(); - if (composition == null) return; - - var recomposer = _capabilityScope.Recompose(composition); - recomposer.AddAs(protector); - recomposer.Build(); - } - private void RegisterProtectorAndKeyInfo(IRuntimeSecretDecryptor protector, ISecretEncryptionKeyInfoProvider keyInfo) { var composition = _capabilityScope.Owner.GetComposition(); @@ -80,7 +70,6 @@ private void ApplySingleKidMode(CertificateProtectorConfig config, IConfiguratio var protector = new SingleKidProtectorWrapper(inventory, config.ForceSingleKid!, config.AdditionalKids); // Publish the current encryption public key for this single, unambiguous kid. - // (Multi-kid / folder mode is decrypt-only here; per-tenant publishing comes with multi-tenancy.) var keyInfo = new InventoryKeyInfoProvider(inventory, config.ForceSingleKid!); RegisterProtectorAndKeyInfo(protector, keyInfo); } @@ -100,9 +89,11 @@ private void ApplyMultiKidMode(CertificateProtectorConfig config, IConfiguration config.CertificateComparer, includeSubdirectories: -1); // Unlimited recursive - watches all kid folders - // Register ONE protector that handles all kids dynamically + // Register ONE protector that handles all kids dynamically, plus a folder-aware key-info + // provider that publishes the current public key per kid (= per tenant) on demand. var protector = new X509HybridFolderSecretProtector(globalInventory); - RegisterProtector(protector); + var keyInfo = new FolderKeyInfoProvider(globalInventory); + RegisterProtectorAndKeyInfo(protector, keyInfo); } private static void ValidateCertificateStructure(CertificateProtectorConfig config) diff --git a/src/Cocoar.Configuration/Secrets/Protectors/Hybrid/InventoryKeyInfoProvider.cs b/src/Cocoar.Configuration/Secrets/Protectors/Hybrid/InventoryKeyInfoProvider.cs index 53cf733..302f32e 100644 --- a/src/Cocoar.Configuration/Secrets/Protectors/Hybrid/InventoryKeyInfoProvider.cs +++ b/src/Cocoar.Configuration/Secrets/Protectors/Hybrid/InventoryKeyInfoProvider.cs @@ -28,10 +28,10 @@ public InventoryKeyInfoProvider(CertificateInventory inventory, string kid) return new SecretEncryptionPublicKey { Kid = _kid, - PublicKey = ToBase64Url(spki), + PublicKey = SpkiEncoding.ToBase64Url(spki), }; } - private static string ToBase64Url(byte[] bytes) - => Convert.ToBase64String(bytes).Replace('+', '-').Replace('/', '_').TrimEnd('='); + public SecretEncryptionPublicKey? TryGetKeyForKid(string kid) + => string.Equals(kid, _kid, StringComparison.OrdinalIgnoreCase) ? TryGetCurrentKey() : null; } diff --git a/src/Cocoar.Configuration/Secrets/Protectors/Hybrid/SpkiEncoding.cs b/src/Cocoar.Configuration/Secrets/Protectors/Hybrid/SpkiEncoding.cs new file mode 100644 index 0000000..053baba --- /dev/null +++ b/src/Cocoar.Configuration/Secrets/Protectors/Hybrid/SpkiEncoding.cs @@ -0,0 +1,11 @@ +namespace Cocoar.Configuration.Secrets.Protectors.Hybrid; + +/// +/// Encodes a DER SubjectPublicKeyInfo as base64url WITHOUT padding — the same codec the +/// cocoar.secret envelope wire format and the @cocoar/secrets browser library use. +/// +internal static class SpkiEncoding +{ + public static string ToBase64Url(byte[] spki) + => Convert.ToBase64String(spki).Replace('+', '-').Replace('/', '_').TrimEnd('='); +} diff --git a/src/tests/Cocoar.Configuration.AspNetCore.Tests/SecretEncryptionKeyEndpointTests.cs b/src/tests/Cocoar.Configuration.AspNetCore.Tests/SecretEncryptionKeyEndpointTests.cs index a701259..b1ffc32 100644 --- a/src/tests/Cocoar.Configuration.AspNetCore.Tests/SecretEncryptionKeyEndpointTests.cs +++ b/src/tests/Cocoar.Configuration.AspNetCore.Tests/SecretEncryptionKeyEndpointTests.cs @@ -1,6 +1,5 @@ using System.Net; using System.Net.Http.Json; -using System.Security.Cryptography; using System.Text.Encodings.Web; using System.Text.Json; using Cocoar.Configuration.AspNetCore; @@ -9,6 +8,7 @@ using Cocoar.Configuration.X509Encryption; using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.TestHost; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; @@ -23,11 +23,13 @@ public class SecretEncryptionKeyEndpointTests : IAsyncDisposable private IHost? _host; private HttpClient? _client; private string? _pfxPath; + private string? _certFolder; - private const string BasePattern = "/.well-known/cocoar/encryption-keys"; + private const string Pattern = "/.well-known/cocoar/encryption-key"; - /// Host with single-kid secrets configured (so a key is publishable). - private async Task CreateHostWithSecrets( + // ---------- single-tenant (single-kid) hosts ---------- + + private async Task CreateSingleTenantHost( string kid, Action? configureJson = null) { @@ -43,11 +45,10 @@ private async Task CreateHostWithSecrets( builder.WebHost.UseTestServer(); var app = builder.Build(); - app.MapSecretEncryptionKeyEndpoints(); + app.MapSecretEncryptionKey(); return await StartAsync(app); } - /// Host with NO secrets configured (the accessor service is not registered). private async Task CreateHostWithoutSecrets() { var builder = WebApplication.CreateBuilder(); @@ -55,11 +56,10 @@ private async Task CreateHostWithoutSecrets() builder.WebHost.UseTestServer(); var app = builder.Build(); - app.MapSecretEncryptionKeyEndpoints(); + app.MapSecretEncryptionKey(); return await StartAsync(app); } - /// Host that gates both routes behind authorization with a deny-all scheme. private async Task CreateHostWithAuth() { var builder = WebApplication.CreateBuilder(); @@ -73,7 +73,32 @@ private async Task CreateHostWithAuth() var app = builder.Build(); app.UseAuthentication(); app.UseAuthorization(); - app.MapSecretEncryptionKeyEndpoints().RequireAuthorization(); + app.MapSecretEncryptionKey().RequireAuthorization(); + return await StartAsync(app); + } + + // ---------- multi-tenant (folder, kid = tenant) host ---------- + + private async Task CreateTenantHost(string? resolvedTenant, params string[] tenants) + { + _certFolder = Path.Combine(Path.GetTempPath(), "cocoar_ep_mt_" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(_certFolder); + foreach (var t in tenants) + { + var dir = Path.Combine(_certFolder, t); + Directory.CreateDirectory(dir); + using (X509CertificateGenerator.GenerateAndSavePfx(Path.Combine(dir, "cert.pfx"), password: null, $"CN={t}", overwrite: true)) { } + } + + var builder = WebApplication.CreateBuilder(); + builder.Services.AddCocoarConfiguration(c => c + .UseConfiguration(rules => []) + .UseSecretsSetup(secrets => secrets.UseCertificatesFromFolder(_certFolder))); + builder.Services.AddScoped(_ => new FixedTenantContext(resolvedTenant)); + builder.WebHost.UseTestServer(); + + var app = builder.Build(); + app.MapTenantSecretEncryptionKey(); return await StartAsync(app); } @@ -91,23 +116,25 @@ public async ValueTask DisposeAsync() if (_host != null) await _host.StopAsync(); _host?.Dispose(); if (_pfxPath != null && File.Exists(_pfxPath)) File.Delete(_pfxPath); + if (_certFolder != null && Directory.Exists(_certFolder)) + { + try { Directory.Delete(_certFolder, recursive: true); } catch { /* best effort */ } + } GC.SuppressFinalize(this); } + // ---------- single-tenant tests ---------- + [Fact] - public async Task List_WithSingleKidSecrets_Returns200WithOneKey() + public async Task SingleKey_WithSecrets_Returns200WithOneKey() { const string kid = "endpoint-kid"; - var client = await CreateHostWithSecrets(kid); + var client = await CreateSingleTenantHost(kid); - var response = await client.GetAsync(BasePattern); + var response = await client.GetAsync(Pattern); Assert.Equal(HttpStatusCode.OK, response.StatusCode); - var json = await response.Content.ReadFromJsonAsync(); - var keys = json.GetProperty("keys"); - Assert.Equal(1, keys.GetArrayLength()); - - var key = keys[0]; + var key = await response.Content.ReadFromJsonAsync(); Assert.Equal(kid, key.GetProperty("kid").GetString()); Assert.Equal("spki", key.GetProperty("format").GetString()); Assert.Equal("base64url", key.GetProperty("encoding").GetString()); @@ -117,88 +144,129 @@ public async Task List_WithSingleKidSecrets_Returns200WithOneKey() } [Fact] - public async Task ByKid_KnownKid_Returns200() + public async Task SingleKey_NoSecrets_Returns404() { - const string kid = "by-kid"; - var client = await CreateHostWithSecrets(kid); + var client = await CreateHostWithoutSecrets(); - var response = await client.GetAsync($"{BasePattern}/{kid}"); + var response = await client.GetAsync(Pattern); - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - var json = await response.Content.ReadFromJsonAsync(); - Assert.Equal(kid, json.GetProperty("kid").GetString()); - Assert.False(string.IsNullOrEmpty(json.GetProperty("publicKey").GetString())); + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); } [Fact] - public async Task ByKid_UnknownKid_Returns404() + public async Task SingleKey_RequireAuthorization_GatesTheRoute() { - var client = await CreateHostWithSecrets("configured-kid"); + var client = await CreateHostWithAuth(); - var response = await client.GetAsync($"{BasePattern}/some-other-kid"); + var response = await client.GetAsync(Pattern); - Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + Assert.True( + response.StatusCode is HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden, + $"expected 401/403 but got {(int)response.StatusCode}"); } [Fact] - public async Task List_NoSecrets_Returns200WithEmptyKeys() + public async Task SingleKey_WithCustomJsonNamingPolicy_KeepsPinnedFieldNames() { - var client = await CreateHostWithoutSecrets(); + const string kid = "policy-kid"; + var client = await CreateSingleTenantHost( + kid, + o => o.SerializerOptions.PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseUpper); - var response = await client.GetAsync(BasePattern); + var response = await client.GetAsync(Pattern); Assert.Equal(HttpStatusCode.OK, response.StatusCode); - var json = await response.Content.ReadFromJsonAsync(); - Assert.Equal(0, json.GetProperty("keys").GetArrayLength()); + var raw = await response.Content.ReadAsStringAsync(); + + // Every record field is pinned via [JsonPropertyName], so a non-default host naming policy + // must NOT rename them. + Assert.Contains("\"kid\"", raw); + Assert.Contains("\"publicKey\"", raw); + Assert.DoesNotContain("PUBLIC_KEY", raw); } + // ---------- multi-tenant tests ---------- + [Fact] - public async Task ByKid_NoSecrets_Returns404() + public async Task TenantKey_ResolvedTenant_Returns200WithOnlyThatTenantsKey() { - var client = await CreateHostWithoutSecrets(); + var client = await CreateTenantHost(resolvedTenant: "tenantA", "tenantA", "tenantB"); - var response = await client.GetAsync($"{BasePattern}/anything"); + var response = await client.GetAsync(Pattern); - Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var key = await response.Content.ReadFromJsonAsync(); + Assert.Equal("tenantA", key.GetProperty("kid").GetString()); + Assert.False(string.IsNullOrEmpty(key.GetProperty("publicKey").GetString())); + + // Single key object — never a list / no other tenant exposed. + Assert.Equal(JsonValueKind.Object, key.ValueKind); } [Fact] - public async Task RequireAuthorization_GatesBothRoutes() + public async Task TenantKey_NoTenantResolved_Returns400() { - var client = await CreateHostWithAuth(); + var client = await CreateTenantHost(resolvedTenant: null, "tenantA"); - var list = await client.GetAsync(BasePattern); - var byKid = await client.GetAsync($"{BasePattern}/any"); + var response = await client.GetAsync(Pattern); - // A single .RequireAuthorization() on the composite builder must apply to BOTH routes. - Assert.True( - list.StatusCode is HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden, - $"list expected 401/403 but got {(int)list.StatusCode}"); - Assert.True( - byKid.StatusCode is HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden, - $"by-kid expected 401/403 but got {(int)byKid.StatusCode}"); + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); } [Fact] - public async Task List_WithCustomJsonNamingPolicy_KeepsPinnedFieldNames() + public async Task TenantKey_UnknownTenant_Returns404() { - const string kid = "policy-kid"; - var client = await CreateHostWithSecrets( - kid, - o => o.SerializerOptions.PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseUpper); + var client = await CreateTenantHost(resolvedTenant: "ghost", "tenantA"); + + var response = await client.GetAsync(Pattern); + + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + } + + [Fact] + public async Task TenantKey_ViaHttpContextAccessorResolver_ReturnsResolvedTenantsKey() + { + // The HTTP transport is just AddCocoarTenantResolver — no AspNetCore-specific + // resolver API. Here the tenant comes from a request header. + _certFolder = Path.Combine(Path.GetTempPath(), "cocoar_ep_http_" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(_certFolder); + foreach (var t in new[] { "tenantA", "tenantB" }) + { + var dir = Path.Combine(_certFolder, t); + Directory.CreateDirectory(dir); + using (X509CertificateGenerator.GenerateAndSavePfx(Path.Combine(dir, "cert.pfx"), password: null, $"CN={t}", overwrite: true)) { } + } - var response = await client.GetAsync(BasePattern); + var builder = WebApplication.CreateBuilder(); + builder.Services.AddCocoarConfiguration(c => c + .UseConfiguration(rules => []) + .UseSecretsSetup(secrets => secrets.UseCertificatesFromFolder(_certFolder))); + builder.Services.AddHttpContextAccessor(); + builder.Services.AddCocoarTenantResolver(a => + { + var header = a.HttpContext?.Request.Headers["X-Tenant"].ToString(); + return string.IsNullOrEmpty(header) ? null : header; + }); + builder.WebHost.UseTestServer(); + + var app = builder.Build(); + app.MapTenantSecretEncryptionKey(); + var client = await StartAsync(app); + + var request = new HttpRequestMessage(HttpMethod.Get, Pattern); + request.Headers.Add("X-Tenant", "tenantA"); + var response = await client.SendAsync(request); Assert.Equal(HttpStatusCode.OK, response.StatusCode); - var raw = await response.Content.ReadAsStringAsync(); + var key = await response.Content.ReadFromJsonAsync(); + Assert.Equal("tenantA", key.GetProperty("kid").GetString()); + } - // The list wrapper ("keys") and every record field are pinned via [JsonPropertyName], - // so a non-default host naming policy must NOT rename them. - Assert.Contains("\"keys\"", raw); - Assert.Contains("\"kid\"", raw); - Assert.Contains("\"publicKey\"", raw); - Assert.DoesNotContain("KEYS", raw); - Assert.DoesNotContain("PUBLIC_KEY", raw); + private sealed class FixedTenantContext : ITenantContext + { + public FixedTenantContext(string? current) => Current = current; + + public string? Current { get; } } private sealed class DenyAllHandler : AuthenticationHandler diff --git a/src/tests/Cocoar.Configuration.MultiTenant.Tests/TenantSecretEncryptionKeyTests.cs b/src/tests/Cocoar.Configuration.MultiTenant.Tests/TenantSecretEncryptionKeyTests.cs new file mode 100644 index 0000000..3206e71 --- /dev/null +++ b/src/tests/Cocoar.Configuration.MultiTenant.Tests/TenantSecretEncryptionKeyTests.cs @@ -0,0 +1,171 @@ +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; +using System.Text.Json; +using Cocoar.Configuration.Core; +using Cocoar.Configuration.DI; +using Cocoar.Configuration.Providers; // FromStaticJson / FromStore / IStoreBackend / GetWritableStoreForTenant +using Cocoar.Configuration.Secrets; +using Cocoar.Configuration.Secrets.SecretTypes; +using Cocoar.Configuration.X509Encryption; +using Microsoft.Extensions.DependencyInjection; + +namespace Cocoar.Configuration.MultiTenant.Tests; + +/// +/// (Secrets publishing) per-tenant encryption-key publishing on the folder (kid = tenant) model: +/// each tenant publishes exactly ONE current public key (the newest cert in its subfolder) and the +/// provider never exposes another tenant's key. The published key round-trips end to end: a producer +/// encrypts with it, the value is written to THAT tenant's WritableStore overlay, and the tenant +/// decrypts the original — closing the loop that motivated multi-tenancy. +/// +[Trait("Category", "MultiTenant")] +[Trait("Type", "Unit")] +public sealed class TenantSecretEncryptionKeyTests : IDisposable +{ + private readonly string _certsRoot = Path.Combine(Path.GetTempPath(), "cocoar-mt-keypub-" + Guid.NewGuid().ToString("N")); + + [Fact] + public void GetCurrentKeyForTenant_PublishesPerTenantKey_NoCrossTenantExposure() + { + using var certA = GenerateTenantCert("tenantA"); + using var certB = GenerateTenantCert("tenantB"); + + var services = new ServiceCollection(); + services.AddCocoarConfiguration(c => c + .UseConfiguration(rules => []) + .UseSecretsSetup(secrets => secrets.UseCertificatesFromFolder(_certsRoot))); + using var provider = services.BuildServiceProvider(); + + var keyProvider = provider.GetRequiredService(); + + var keyA = keyProvider.GetCurrentKeyForTenant("tenantA"); + var keyB = keyProvider.GetCurrentKeyForTenant("tenantB"); + + Assert.NotNull(keyA); + Assert.NotNull(keyB); + Assert.Equal("tenantA", keyA!.Kid); + Assert.Equal("tenantB", keyB!.Kid); + + // Each tenant's published key is exactly its OWN cert's SPKI — never the other's. + Assert.Equal(ExpectedSpki(certA), keyA.PublicKey); + Assert.Equal(ExpectedSpki(certB), keyB.PublicKey); + Assert.NotEqual(keyA.PublicKey, keyB.PublicKey); + + // No publishable key for an unknown tenant, and the single-tenant accessor yields nothing + // in folder/multi-tenant mode (callers MUST ask per tenant — no list, no leak). + Assert.Null(keyProvider.GetCurrentKeyForTenant("tenantC")); + Assert.Null(keyProvider.GetCurrentKey()); + } + + [Fact] + public async Task PublishedTenantKey_EncryptsValue_WrittenToTenantStore_Decrypts() + { + using var certA = GenerateTenantCert("tenantA"); + using var certB = GenerateTenantCert("tenantB"); + + var backends = new Dictionary(); + IStoreBackend BackendFor(string? tenant) + { + if (!backends.TryGetValue(tenant ?? "", out var b)) backends[tenant ?? ""] = b = new InMemoryBackend(); + return b; + } + + var services = new ServiceCollection(); + services.AddCocoarConfiguration(c => c + .UseConfiguration(rules => + [ + rules.For().FromStaticJson("{}"), + rules.For().FromStore((a, _) => BackendFor(a.Tenant)).TenantScoped(), + ]) + .UseSecretsSetup(secrets => secrets.UseCertificatesFromFolder(_certsRoot)) + .UseDebounce(25)); + + using var provider = services.BuildServiceProvider(); + var mgr = provider.GetRequiredService(); + var tenants = (ITenantConfigurationAccessor)mgr; + await tenants.InitializeTenantAsync("tenantA"); + + // Producer fetches ONLY tenant A's published public key — never the private cert. + var published = provider.GetRequiredService().GetCurrentKeyForTenant("tenantA"); + Assert.NotNull(published); + Assert.Equal("tenantA", published!.Kid); + + // Encrypt client-side with that public key alone, then write into tenant A's overlay. + using var rsaPublic = RSA.Create(); + rsaPublic.ImportSubjectPublicKeyInfo(FromBase64Url(published.PublicKey), out _); + await mgr.GetWritableStoreForTenant("tenantA") + .SetSecretAsync(x => x.ApiKey!, EncryptForKid(rsaPublic, "tenantA", "secret-A")); + + await TenantWait.UntilAsync( + () => mgr.GetConfigForTenant("tenantA")?.ApiKey is not null, "tenant A secret applied"); + + using var lease = mgr.GetConfigForTenant("tenantA")!.ApiKey!.Open(); + Assert.Equal("secret-A", lease.Value); + } + + private X509Certificate2 GenerateTenantCert(string kid) + { + var kidFolder = Path.Combine(_certsRoot, kid); + Directory.CreateDirectory(kidFolder); + var pfxPath = Path.Combine(kidFolder, "cert.pfx"); + return X509CertificateGenerator.GenerateAndSavePfx(pfxPath, password: null, $"CN=Cocoar {kid}", overwrite: true); + } + + private static string ExpectedSpki(X509Certificate2 cert) + { + using var rsa = cert.GetRSAPublicKey()!; + return ToBase64Url(rsa.ExportSubjectPublicKeyInfo()); + } + + public void Dispose() + { + try { if (Directory.Exists(_certsRoot)) Directory.Delete(_certsRoot, recursive: true); } catch { /* best effort */ } + } + + // ---- client-side envelope encryption (what a browser/producer does with only the public key) ---- + + private static SecretEnvelope EncryptForKid(RSA rsaPublic, string kid, string value) + { + var plaintext = JsonSerializer.SerializeToUtf8Bytes(value); + + Span dek = stackalloc byte[32]; + RandomNumberGenerator.Fill(dek); + try + { + var iv = new byte[12]; + RandomNumberGenerator.Fill(iv); + var ct = new byte[plaintext.Length]; + var tag = new byte[16]; + + using (var aes = new AesGcm(dek, tag.Length)) + { + aes.Encrypt(iv, plaintext, ct, tag, associatedData: null); + } + + var wk = rsaPublic.Encrypt(dek.ToArray(), RSAEncryptionPadding.OaepSHA256); + + return new SecretEnvelope + { + Kid = kid, + Wk = ToBase64Url(wk), + Iv = ToBase64Url(iv), + Ct = ToBase64Url(ct), + Tag = ToBase64Url(tag), + }; + } + finally + { + CryptographicOperations.ZeroMemory(dek); + } + } + + private static string ToBase64Url(byte[] bytes) + => Convert.ToBase64String(bytes).Replace('+', '-').Replace('/', '_').TrimEnd('='); + + private static byte[] FromBase64Url(string value) + { + var b64 = value.Replace('-', '+').Replace('_', '/'); + b64 += (b64.Length % 4) switch { 2 => "==", 3 => "=", _ => string.Empty }; + return Convert.FromBase64String(b64); + } +} diff --git a/src/tests/Cocoar.Configuration.Providers.Tests/SecretEncryptionKeyProviderTests.cs b/src/tests/Cocoar.Configuration.Providers.Tests/SecretEncryptionKeyProviderTests.cs index ea013c8..3b93fe0 100644 --- a/src/tests/Cocoar.Configuration.Providers.Tests/SecretEncryptionKeyProviderTests.cs +++ b/src/tests/Cocoar.Configuration.Providers.Tests/SecretEncryptionKeyProviderTests.cs @@ -29,16 +29,17 @@ public sealed class VaultConfig } [Fact] - public void GetCurrentKeys_SingleKid_PublishesOnePublicKey() + public void GetCurrentKey_SingleKid_PublishesOnePublicKey() { const string kid = "publish-kid"; RunWithCert(kid, provider => { var keyProvider = provider.GetRequiredService(); - var key = Assert.Single(keyProvider.GetCurrentKeys()); + var key = keyProvider.GetCurrentKey(); + Assert.NotNull(key); - Assert.Equal(kid, key.Kid); + Assert.Equal(kid, key!.Kid); Assert.Equal(SecretAlgorithms.Hybrid, key.Alg); Assert.Equal(SecretAlgorithms.KeyWrap, key.Walg); Assert.Equal(SecretAlgorithms.DataEncryption, key.Enc); @@ -58,16 +59,22 @@ public void GetCurrentKeys_SingleKid_PublishesOnePublicKey() } [Fact] - public void GetCurrentKey_ReturnsKeyForConfiguredKid_NullOtherwise() + public void GetCurrentKeyForTenant_ReturnsKeyForConfiguredKid_NullOtherwise() { const string kid = "lookup-kid"; RunWithCert(kid, provider => { var keyProvider = provider.GetRequiredService(); - Assert.NotNull(keyProvider.GetCurrentKey(kid)); - Assert.Null(keyProvider.GetCurrentKey("not-configured")); - Assert.Null(keyProvider.GetCurrentKey("")); + // Single-tenant accessor publishes the one configured key. + Assert.NotNull(keyProvider.GetCurrentKey()); + + // The same key is reachable by its kid (single-kid mode treats kid == tenant id). + Assert.NotNull(keyProvider.GetCurrentKeyForTenant(kid)); + + // Anything else returns nothing — never a list, never another key. + Assert.Null(keyProvider.GetCurrentKeyForTenant("not-configured")); + Assert.Null(keyProvider.GetCurrentKeyForTenant("")); }); } @@ -104,7 +111,7 @@ public async Task PublishedPublicKey_EncryptsValue_ServerDecryptsToOriginal() using var provider = services.BuildServiceProvider(); // 1. Fetch ONLY the published public key — the producer never sees the private cert. - var published = provider.GetRequiredService().GetCurrentKey(kid); + var published = provider.GetRequiredService().GetCurrentKey(); Assert.NotNull(published); // 2. Encrypt client-side with that public key alone (what a browser does). diff --git a/website/guide/secrets/key-publishing.md b/website/guide/secrets/key-publishing.md index fc64428..36fd9a8 100644 --- a/website/guide/secrets/key-publishing.md +++ b/website/guide/secrets/key-publishing.md @@ -2,47 +2,51 @@ Secrets are encrypted with the **public** half of an X.509 certificate and decrypted server-side with the private half (see [Encryption Setup](/guide/secrets/encryption-setup)). To let an **external producer** — a browser form, a CLI, another service — build a `cocoar.secret` envelope your server can later decrypt, you publish the **public key** over an HTTP endpoint. -Only public-key material is ever exposed. The private key never leaves the server, and no plaintext is reachable through this API. +Only public-key material is ever exposed. The private key never leaves the server, and no plaintext is reachable through this API. Each endpoint returns **exactly one key** — never a list — so one tenant's key can never expose another's. -## Mapping the endpoints (ASP.NET Core) +## Single-tenant -`Cocoar.Configuration.AspNetCore` maps the well-known endpoints: +When secrets are configured with one current key (single-kid mode via `UseCertificateFromFile`), map the single-key endpoint: ```csharp -app.MapSecretEncryptionKeyEndpoints(); // list + by-kid, under /.well-known/cocoar/encryption-keys +app.MapSecretEncryptionKey(); // GET /.well-known/cocoar/encryption-key ``` -This maps two routes and returns a single `IEndpointConventionBuilder`, so one convention (e.g. `.RequireAuthorization()`) covers both: - | Route | Returns | |---|---| -| `GET /.well-known/cocoar/encryption-keys` | `{ "keys": [ … ] }` — the current public key per configured kid (always `200`; empty list when nothing is publishable) | -| `GET /.well-known/cocoar/encryption-keys/{kid}` | the public key for one kid, or `404` ProblemDetails when that kid is not published | +| `GET /.well-known/cocoar/encryption-key` | the current public key, or `404` ProblemDetails when nothing is publishable | -Map them individually instead if you only need one: +Pass a custom pattern if the default route doesn't fit: ```csharp -app.MapSecretEncryptionKeys(); // just the list -app.MapSecretEncryptionKeyByKid(); // just the by-kid lookup +app.MapSecretEncryptionKey("/keys/cocoar"); ``` -Pass a custom base pattern if the default route doesn't fit: +## Multi-tenant + +In multi-tenant deployments each tenant has its own certificate(s) under a `kid = tenant` subfolder (`basePath/{tenant}/cert.pfx`, configured with `UseCertificatesFromFolder`). The per-tenant endpoint returns **only the current key of the tenant the request already resolves to** — it never lists keys and never exposes another tenant: ```csharp -app.MapSecretEncryptionKeyEndpoints("/keys/cocoar"); +app.MapTenantSecretEncryptionKey(); // GET /.well-known/cocoar/encryption-key ``` +The tenant is read from `ITenantContext.Current` — your app supplies it from auth, subdomain, or route (the same seam used by [scoped tenant config](/guide/multi-tenancy/overview)), never from a client-chosen value. Register it via `AddCocoarTenantResolver(s => s.TenantId)` (HTTP: `AddCocoarTenantResolver(...)`) or your own scoped `ITenantContext`. + +| Route | Returns | +|---|---| +| `GET /.well-known/cocoar/encryption-key` | the resolved tenant's current public key; `404` when that tenant has none; `400` when no tenant is resolved | + ::: warning Not secured by default -Like `MapFeatureFlagEndpoints`, these routes are **open** unless you secure them. Public keys are safe to expose, but if you want them behind auth, chain `.RequireAuthorization()` — one call on the composite builder covers both routes: +Like `MapFeatureFlagEndpoints`, these routes are **open** unless you secure them. Public keys are safe to expose, but to put them behind auth chain `.RequireAuthorization()`: ```csharp -app.MapSecretEncryptionKeyEndpoints().RequireAuthorization(); +app.MapTenantSecretEncryptionKey().RequireAuthorization(); ``` ::: ## Response shape -Each published key is the current public key for one `kid`: +The endpoint returns the current public key directly (no list wrapper): ```json { @@ -56,18 +60,23 @@ Each published key is the current public key for one `kid`: } ``` -The list endpoint wraps these as `{ "keys": [ … ] }`. The `keys` field name is pinned, so a host JSON naming policy can't rename it. There is exactly **one current key per kid** — the certificate the decryption engine prefers — and key material is re-read on every request, so certificate rotation is reflected without a restart. +Every field name is pinned, so a host JSON naming policy can't rename it. There is exactly **one current key per tenant** — the **newest certificate** in that tenant's set (per the configured certificate comparer; the default orders by file name). Older certificates stay available for **decryption only** (rotation). Key material is re-read on every request, so adding a newer certificate is reflected without a restart. ## How a producer uses it -1. Fetch the key for the kid it should encrypt to. `alg` / `walg` / `enc` describe the scheme; `publicKey` is the SPKI to import. +1. Fetch the current key. `alg` / `walg` / `enc` describe the scheme; `publicKey` is the SPKI to import. 2. Generate a random AES-256 DEK, encrypt the value with AES-GCM, wrap the DEK with RSA-OAEP-256, and assemble the `cocoar.secret` envelope (with `kid` stamped from the key). 3. Send the envelope to your server. It is stored as-is and decrypted only on `Secret.Open()`. -The envelope wire format is documented in [Custom Providers → Secrets](/guide/providers/custom#secrets-in-custom-providers). The same envelope can be written through a WritableStore overlay via `SetSecretEnvelopeAsync` / `SetSecretAsync`. +The envelope wire format is documented in [Custom Providers → Secrets](/guide/providers/custom#secrets-in-custom-providers). The same envelope can be written through a WritableStore overlay via `SetSecretEnvelopeAsync` / `SetSecretAsync` — including per tenant with `GetWritableStoreForTenant(tenantId).SetSecretAsync(...)`, which is how a tenant stores a secret encrypted to its own published key. ## Availability -Publishing is available when secrets are configured via [`UseSecretsSetup`](/guide/secrets/encryption-setup) with a single, unambiguous current key (single-kid mode). When nothing is publishable — no secrets configured — the list endpoint returns `{ "keys": [] }` and the by-kid endpoint returns `404`. Multi-kid / folder mode is decrypt-only for now, so it publishes nothing; per-kid (per-tenant) publishing is planned. +Publishing is available when secrets are configured via [`UseSecretsSetup`](/guide/secrets/encryption-setup): + +- **Single-kid** (`UseCertificateFromFile`) publishes one key via `GetCurrentKey()` / `MapSecretEncryptionKey`. +- **Folder / multi-tenant** (`UseCertificatesFromFolder`, `kid = tenant`) publishes one key per tenant via `GetCurrentKeyForTenant(tenantId)` / `MapTenantSecretEncryptionKey`. + +When no secrets are configured, the service is not registered and the endpoint returns `404`. -The DI service behind the endpoints is `ISecretEncryptionKeyProvider` (`GetCurrentKeys()` / `GetCurrentKey(kid)`), registered wherever secrets are configured — resolve it directly to build your own endpoint or workflow. +The DI service behind the endpoints is `ISecretEncryptionKeyProvider` (`GetCurrentKey()` / `GetCurrentKeyForTenant(tenantId)`), registered wherever secrets are configured — resolve it directly to build your own controller (e.g. one that already knows the tenant) or workflow. From 995bc3447ca0a0bd80ad3ec4ca6d2ee5e822b440 Mon Sep 17 00:00:00 2001 From: Bernhard Windisch Date: Sun, 31 May 2026 09:13:37 +0200 Subject: [PATCH 09/18] refactor: move tenant context resolution from AspNetCore to DI package MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ITenantContext, ITenantReactiveConfig, TenantReactiveConfig and AddCocoarTenantResolver/AddCocoarTenantReactiveConfig now live in Cocoar.Configuration.DI — no ASP.NET Core dependency required. A single generic AddCocoarTenantResolver(selector) overload covers both HTTP (via IHttpContextAccessor) and non-HTTP (via IServiceProvider) hosts. The no-DI tenant path (UseTenantContext / GetConfigForCurrentTenant) was intentionally not added: ambient tenant resolution is a DI/scope concept; no-DI callers use the explicit ForTenant(id) API. Co-Authored-By: Claude Sonnet 4.6 --- .../ServiceDescriptorEmitter.cs | 6 +- .../Tenant/ITenantContext.cs | 14 ++++ .../Tenant/ITenantReactiveConfig.cs | 18 +++++ .../Tenant/TenantConfigExtensions.cs | 76 +++++++++++++++++++ .../Tenant/TenantReactiveConfig.cs | 39 ++++++++++ .../TenantReactiveConfigTests.cs | 1 - .../TenantResolverTests.cs | 58 ++++++++++++++ website/adr/ADR-006-di-aware-configuration.md | 14 ++-- 8 files changed, 214 insertions(+), 12 deletions(-) create mode 100644 src/Cocoar.Configuration.DI/Tenant/ITenantContext.cs create mode 100644 src/Cocoar.Configuration.DI/Tenant/ITenantReactiveConfig.cs create mode 100644 src/Cocoar.Configuration.DI/Tenant/TenantConfigExtensions.cs create mode 100644 src/Cocoar.Configuration.DI/Tenant/TenantReactiveConfig.cs create mode 100644 src/tests/Cocoar.Configuration.MultiTenant.Tests/TenantResolverTests.cs diff --git a/src/Cocoar.Configuration.DI/ServiceDescriptorEmitter.cs b/src/Cocoar.Configuration.DI/ServiceDescriptorEmitter.cs index ea1a5af..8991460 100644 --- a/src/Cocoar.Configuration.DI/ServiceDescriptorEmitter.cs +++ b/src/Cocoar.Configuration.DI/ServiceDescriptorEmitter.cs @@ -89,9 +89,9 @@ private static void EmitProviderContributedServices(IServiceCollection services, /// /// Registers the public when a publishable encryption - /// key is configured (single-kid secrets compose an ). - /// The provider resolves the capability lazily per call so certificate rotation is reflected. - /// Not registered when no publishable key exists (no secrets, or decrypt-only folder mode). + /// key is configured — single-kid and folder/multi-tenant secrets each compose an + /// . The provider resolves the capability lazily per + /// call so certificate rotation is reflected. Not registered when no secrets are configured. /// private static void EmitSecretsKeyProviderServices(IServiceCollection services, ConfigManager configManager) { diff --git a/src/Cocoar.Configuration.DI/Tenant/ITenantContext.cs b/src/Cocoar.Configuration.DI/Tenant/ITenantContext.cs new file mode 100644 index 0000000..775f891 --- /dev/null +++ b/src/Cocoar.Configuration.DI/Tenant/ITenantContext.cs @@ -0,0 +1,14 @@ +namespace Cocoar.Configuration.DI; + +/// +/// Supplies the current request/scope's tenant id for per-request configuration consumption (ADR-006 §11). +/// Ambient tenant resolution is a container/scope concern — no-DI hosts pass the tenant explicitly via the +/// …ForTenant(id) methods instead. The application provides a scoped implementation, typically via +/// AddCocoarTenantResolver<TService>(...) (pointing at the app's tenant service, or +/// IHttpContextAccessor for HTTP), or its own. +/// +public interface ITenantContext +{ + /// The current tenant id, or when none is resolved for this scope. + string? Current { get; } +} diff --git a/src/Cocoar.Configuration.DI/Tenant/ITenantReactiveConfig.cs b/src/Cocoar.Configuration.DI/Tenant/ITenantReactiveConfig.cs new file mode 100644 index 0000000..101aacb --- /dev/null +++ b/src/Cocoar.Configuration.DI/Tenant/ITenantReactiveConfig.cs @@ -0,0 +1,18 @@ +using Cocoar.Configuration.Core; +using Cocoar.Configuration.Reactive; + +namespace Cocoar.Configuration.DI; + +/// +/// A scoped reactive configuration view bound to the current request's tenant (from +/// ) — ADR-006 §11. Inject this into scoped/transient consumers to get THIS +/// tenant's effective configuration; it delegates to ConfigManager.GetReactiveConfigForTenant<T>(tenant). +/// +/// A singleton can never have an ambient tenant — it must call GetReactiveConfigForTenant<T>(id) +/// explicitly. This is a distinct interface from (which stays the global, +/// singleton view), so injecting one never breaks the other (the ADR-006 §11 trap). +/// +/// +public interface ITenantReactiveConfig : IReactiveConfig +{ +} diff --git a/src/Cocoar.Configuration.DI/Tenant/TenantConfigExtensions.cs b/src/Cocoar.Configuration.DI/Tenant/TenantConfigExtensions.cs new file mode 100644 index 0000000..e64f3d8 --- /dev/null +++ b/src/Cocoar.Configuration.DI/Tenant/TenantConfigExtensions.cs @@ -0,0 +1,76 @@ +using Cocoar.Configuration.Core; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; + +namespace Cocoar.Configuration.DI; + +/// +/// Registrations for per-request tenant configuration consumption (ADR-006 §11): the scoped +/// adapter, and resolving the current +/// from a DI service. Both are plain DI registrations — no ASP.NET dependency. +/// +public static class TenantConfigExtensions +{ + /// + /// Registers the scoped adapter so scoped/transient consumers can + /// inject the current request's tenant configuration. Leaves the singleton IReactiveConfig<T> + /// registration untouched (the §11 trap). The app must ALSO register a scoped — + /// e.g. via or its own. + /// + public static IServiceCollection AddCocoarTenantReactiveConfig(this IServiceCollection services) + { + ArgumentNullException.ThrowIfNull(services); + services.TryAddScoped(typeof(ITenantReactiveConfig<>), typeof(TenantReactiveConfig<>)); + return services; + } + + /// + /// Registers a scoped that resolves the current tenant from a DI service + /// via . The selector is evaluated lazily on + /// every access, so it reflects the tenant once it is known for the + /// scope (e.g. after auth middleware). + /// + /// The app already has a tenant service — point at it instead of writing an : + /// + /// services.AddCocoarTenantResolver<ApplicationTenantService>(s => s.TenantId); + /// + /// For HTTP, use IHttpContextAccessor as the service (no ASP.NET-specific API needed): + /// + /// services.AddHttpContextAccessor(); + /// services.AddCocoarTenantResolver<IHttpContextAccessor>(a => a.HttpContext?.User.FindFirst("tenant")?.Value); + /// + /// When the tenant comes from more than one service, use IServiceProvider as the service: + /// + /// services.AddCocoarTenantResolver<IServiceProvider>(sp => /* combine services */); + /// + /// + /// + public static IServiceCollection AddCocoarTenantResolver( + this IServiceCollection services, + Func selector) + where TService : notnull + { + ArgumentNullException.ThrowIfNull(services); + ArgumentNullException.ThrowIfNull(selector); + + services.TryAddScoped(sp => + new DelegateTenantContext(() => selector(sp.GetRequiredService()))); + return services; + } +} + +/// +/// An whose defers to a delegate, re-evaluated on every +/// access — so the tenant can become known after the scope starts (e.g. post-auth-middleware). +/// +public sealed class DelegateTenantContext : ITenantContext +{ + private readonly Func _resolve; + + /// Creates a context whose calls on each access. + public DelegateTenantContext(Func resolve) + => _resolve = resolve ?? throw new ArgumentNullException(nameof(resolve)); + + /// + public string? Current => _resolve(); +} diff --git a/src/Cocoar.Configuration.DI/Tenant/TenantReactiveConfig.cs b/src/Cocoar.Configuration.DI/Tenant/TenantReactiveConfig.cs new file mode 100644 index 0000000..5e9de4d --- /dev/null +++ b/src/Cocoar.Configuration.DI/Tenant/TenantReactiveConfig.cs @@ -0,0 +1,39 @@ +using Cocoar.Configuration.Core; +using Cocoar.Configuration.Reactive; + +namespace Cocoar.Configuration.DI; + +/// +/// Scoped adapter that binds to the current request's tenant +/// (from ) and delegates to ConfigManager.GetReactiveConfigForTenant<T>. +/// +internal sealed class TenantReactiveConfig : ITenantReactiveConfig +{ + private readonly Lazy> _inner; + + public TenantReactiveConfig(ConfigManager configManager, ITenantContext tenantContext) + { + ArgumentNullException.ThrowIfNull(configManager); + ArgumentNullException.ThrowIfNull(tenantContext); + + // Resolved lazily on first use: the tenant pipeline must already be initialized (e.g. via + // EnsureTenantInitializedAsync in middleware). Binding to ITenantContext.Current here ties this scoped + // view to THIS request's tenant. + _inner = new Lazy>(() => + { + var tenant = tenantContext.Current; + if (string.IsNullOrWhiteSpace(tenant)) + { + throw new InvalidOperationException( + "No tenant resolved in ITenantContext for the current request. ITenantReactiveConfig " + + "requires a tenant; a singleton must use ConfigManager.GetReactiveConfigForTenant(id) explicitly."); + } + + return configManager.GetReactiveConfigForTenant(tenant); + }); + } + + public T CurrentValue => _inner.Value.CurrentValue; + + public IDisposable Subscribe(IObserver observer) => _inner.Value.Subscribe(observer); +} diff --git a/src/tests/Cocoar.Configuration.AspNetCore.Tests/TenantReactiveConfigTests.cs b/src/tests/Cocoar.Configuration.AspNetCore.Tests/TenantReactiveConfigTests.cs index e39681d..f18231a 100644 --- a/src/tests/Cocoar.Configuration.AspNetCore.Tests/TenantReactiveConfigTests.cs +++ b/src/tests/Cocoar.Configuration.AspNetCore.Tests/TenantReactiveConfigTests.cs @@ -1,4 +1,3 @@ -using Cocoar.Configuration.AspNetCore; using Cocoar.Configuration.Core; using Cocoar.Configuration.DI; using Cocoar.Configuration.Providers; // FromStaticJson / FromStatic diff --git a/src/tests/Cocoar.Configuration.MultiTenant.Tests/TenantResolverTests.cs b/src/tests/Cocoar.Configuration.MultiTenant.Tests/TenantResolverTests.cs new file mode 100644 index 0000000..6a696ea --- /dev/null +++ b/src/tests/Cocoar.Configuration.MultiTenant.Tests/TenantResolverTests.cs @@ -0,0 +1,58 @@ +using Cocoar.Configuration.Core; +using Cocoar.Configuration.DI; +using Cocoar.Configuration.Providers; +using Microsoft.Extensions.DependencyInjection; + +namespace Cocoar.Configuration.MultiTenant.Tests; + +/// +/// The DI tenant resolver: AddCocoarTenantResolver<TService> registers a scoped +/// ITenantContext from the app's own tenant service, and the scoped ITenantReactiveConfig<T> +/// adapter binds to it — no hand-written adapter. (No-DI hosts resolve tenants explicitly via +/// …ForTenant(id), so there is no ambient-context path there.) +/// +[Trait("Category", "MultiTenant")] +[Trait("Type", "Unit")] +public sealed class TenantResolverTests +{ + public sealed record RegionCfg + { + public string Region { get; init; } = "base"; + } + + private sealed class AppTenantService + { + public string? TenantId { get; set; } + } + + [Fact] + public async Task AddCocoarTenantResolver_RegistersScopedTenantContext_FromAppService() + { + var services = new ServiceCollection(); + services.AddCocoarConfiguration(c => c + .UseConfiguration(rules => + [ + rules.For().FromStaticJson("""{ "Region": "base" }"""), + rules.For().FromStatic(a => new RegionCfg { Region = $"region-{a.Tenant}" }).TenantScoped(), + ]) + .UseDebounce(25)); + + // The app already has its own tenant service — point the resolver at it, no ITenantContext to write. + services.AddScoped(); + services.AddCocoarTenantResolver(s => s.TenantId); + services.AddCocoarTenantReactiveConfig(); + + await using var sp = services.BuildServiceProvider(); + var tenants = (ITenantConfigurationAccessor)sp.GetRequiredService(); + await tenants.InitializeTenantAsync("acme"); + + using var scope = sp.CreateScope(); + scope.ServiceProvider.GetRequiredService().TenantId = "acme"; + + // The resolver-provided ITenantContext reflects the app service... + Assert.Equal("acme", scope.ServiceProvider.GetRequiredService().Current); + // ...and the scoped reactive adapter binds to it. + var cfg = scope.ServiceProvider.GetRequiredService>(); + Assert.Equal("region-acme", cfg.CurrentValue.Region); + } +} diff --git a/website/adr/ADR-006-di-aware-configuration.md b/website/adr/ADR-006-di-aware-configuration.md index b3e0298..05513cc 100644 --- a/website/adr/ADR-006-di-aware-configuration.md +++ b/website/adr/ADR-006-di-aware-configuration.md @@ -154,16 +154,14 @@ The holder's `sp` is the **root** `IServiceProvider` (the activation hosted serv Today `HttpProvider` does `new HttpClient()`. Add a Layer-2 overload that resolves `IHttpClientFactory` from the holder and uses a named client — gaining handler pooling/rotation, Polly via `AddHttpClient`, etc. The current `new HttpClient()` / `HttpMessageHandler?` path stays for Layer 1 / no-DI. -### 11. Consumption-tenant adapter (separate, additive, future) +### 11. Consumption-tenant adapter (implemented) -Distinct from the **source-tenant** flow above (`a.Tenant`, build side) is the **consumption-tenant** flow: "this request's tenant's config via injection." That is a *separate* concern and is **not part of this ADR's core**, but is the natural follow-on: +Distinct from the **source-tenant** flow above (`a.Tenant`, build side) is the **consumption-tenant** flow: "this request's tenant's config via injection." A *separate* concern from this ADR's core, built on top of the existing `GetReactiveConfigForTenant`: -- The app supplies a **scoped `ITenantContext`** (reads the current tenant from `HttpContext`: claim/header/route — only the app knows this). -- A **scoped `ITenantReactiveConfig`** adapter (in `Cocoar.Configuration.AspNetCore`) reads `ITenantContext.Current` and delegates to `mgr.GetReactiveConfigForTenant(tenant)`. +- The **`ITenantContext { string? Current }`** abstraction ("who is the current tenant for this request/scope") is **ambient tenant resolution** — a container/scope concern, so it lives in **`Cocoar.Configuration.DI`**. No-DI hosts have no ambient scope; they pass the tenant explicitly via `…ForTenant(id)`. +- **DI:** a scoped **`ITenantReactiveConfig`** adapter (in **`Cocoar.Configuration.DI`**) reads `ITenantContext.Current` and delegates to `mgr.GetReactiveConfigForTenant(tenant)`. The app registers a scoped `ITenantContext` with `AddCocoarTenantResolver(s => s.TenantId)` — pointing at whatever already knows the tenant, no adapter to hand-write. HTTP is simply `AddCocoarTenantResolver(a => a.HttpContext?...)`; there is **no** AspNetCore-specific resolver API. - Scoped/transient consumers only; a singleton can never have an ambient tenant → it uses explicit `GetReactiveConfigForTenant(id)`. -- **Trap:** do **not** re-register `IReactiveConfig` itself as scoped (it is a singleton today; that would break singletons injecting it). Use a **distinct** `ITenantReactiveConfig`. - -It needs nothing from this ADR (only the existing `GetReactiveConfigForTenant`), so it can ship independently, any time. +- **Trap:** do **not** re-register `IReactiveConfig` itself as scoped (it is a singleton; that would break singletons injecting it). Use a **distinct** `ITenantReactiveConfig`. --- @@ -188,7 +186,7 @@ Plus the §11 trap: never re-register `IReactiveConfig` as scoped. | Core `ConfigManagerBuilder` | likely **one small internal hook** to append satellite-supplied rules | Additive (internal) | | **NEW** `Cocoar.Configuration.DI`: `ServiceProviderHolder` + `UseServiceBackedConfiguration` extension + `sp`-aware factory overloads (`FromStore`, …) + activation `IHostedService` | the whole Layer-2 mechanism | **New (satellite)** | | `Cocoar.Configuration.Http` | `FromHttp((sp,a)=>…)` overload resolving `IHttpClientFactory` | Additive | -| (Future) `Cocoar.Configuration.AspNetCore` | scoped `ITenantReactiveConfig` (§11) | Additive | +| `Cocoar.Configuration.DI` | scoped `ITenantReactiveConfig` + `AddCocoarTenantResolver` (§11) | Additive | **Net:** the core gains essentially nothing DI-specific (a small internal append hook at most); the entire DI integration lives in the satellite packages. The No-DI core is preserved. From 8f3c26f40165489e2df8d2f33d43275adc739032 Mon Sep 17 00:00:00 2001 From: Bernhard Windisch Date: Sun, 31 May 2026 09:13:46 +0200 Subject: [PATCH 10/18] =?UTF-8?q?fix:=20mixed-scope=20tuples=20work=20?= =?UTF-8?q?=E2=80=94=20correct=20error=20message=20and=20docs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The documented limit "mixed-scope tuples not supported" was wrong. A tuple element type can have both global and tenant-scoped rules; the tuple reads each element from one pipeline snapshot (atomic). The only genuinely unsupported case is a global tuple containing a type whose every rule is tenant-scoped — that now throws a clear, targeted message instead of silently producing empty config. Docs corrected in ADR-005 and the multi-tenancy guide. MixedScopeTupleTests verifies all three cases empirically. Co-Authored-By: Claude Sonnet 4.6 --- CHANGELOG.md | 6 +- .../Reactive/ReactiveConfigurationFactory.cs | 31 ++++++++++ .../MixedScopeTupleTests.cs | 62 +++++++++++++++++++ .../adr/ADR-005-multi-tenant-configuration.md | 6 +- website/changelog.md | 8 ++- website/guide/multi-tenancy/overview.md | 27 ++++++-- 6 files changed, 126 insertions(+), 14 deletions(-) create mode 100644 src/tests/Cocoar.Configuration.MultiTenant.Tests/MixedScopeTupleTests.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index ea0b9c7..9695a1d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,7 +18,8 @@ - `.TenantScoped()` rule marker + `Tenant` on `IConfigurationAccessor` (default-interface member, non-breaking) — author one flat rule list, no second surface - Per-tenant access: `GetConfigForTenant` / `GetReactiveConfigForTenant` / `GetFeatureFlagsForTenant` / `GetEntitlementsForTenant` / `GetWritableStoreForTenant` - Tenant-only types are excluded from the global DI plan (avoids the captive-dependency bug); per-tenant flags/entitlements need no source-generator change - - ASP.NET Core: scoped `ITenantReactiveConfig` + `ITenantContext`, and `MapTenantFeatureFlagEndpoints()` / `MapTenantEntitlementEndpoints()` + - Tenant config consumption (DI, no ASP.NET dependency): scoped `ITenantReactiveConfig` + `ITenantContext`; `AddCocoarTenantResolver(s => s.TenantId)` resolves the current tenant from any DI service (HTTP via `IHttpContextAccessor`) — no hand-written adapter + - ASP.NET Core: `MapTenantFeatureFlagEndpoints()` / `MapTenantEntitlementEndpoints()` - **Service-Backed (DI-aware) configuration** — a two-layer model so config providers can use DI-managed services (ADR-006) - `UseServiceBackedConfiguration(...)` (DI package) — Layer-2 rules whose provider factories receive the application `IServiceProvider` - `FromStore((sp, a) => IStoreBackend)`, `FromHttp((sp, a) => HttpClient)`, and `FromService(s => config)` overloads @@ -26,7 +27,8 @@ - public `ServiceBackedProviderBuilder` seam so third-party provider packages can author their own `(sp, a)` overloads - ServiceBackedConfig example project - **Secrets encryption-key publishing** — publish the public half of the configured secrets encryption key so a browser/CLI producer can build `cocoar.secret` envelopes - - `ISecretEncryptionKeyProvider` (DI) and ASP.NET Core `MapSecretEncryptionKeyEndpoints()` under `/.well-known/cocoar/encryption-keys` + - `ISecretEncryptionKeyProvider` (`GetCurrentKey()` / `GetCurrentKeyForTenant(tenantId)`) returns exactly one current public key — the newest cert (per the configured comparer); older certs stay decrypt-only for rotation + - ASP.NET Core `MapSecretEncryptionKey()` (single-tenant) and `MapTenantSecretEncryptionKey()` (per-tenant; tenant from `ITenantContext`) at `/.well-known/cocoar/encryption-key` — one key per request, never a list, no cross-tenant exposure - `SecretEnvelope` for typed secret-overlay writes; WritableStore `SetSecretAsync` / `SetSecretEnvelopeAsync` accept pre-encrypted envelopes - Public `ProviderObservable` / `ProviderDisposable` helpers (in `Cocoar.Configuration.Providers.Abstractions`) for authoring a custom provider's change stream without referencing System.Reactive - `FromFile(a => …)` config-aware file-path overload (resolves the path from the accessor per recompute) — the natural shape for per-tenant file rules diff --git a/src/Cocoar.Configuration/Reactive/ReactiveConfigurationFactory.cs b/src/Cocoar.Configuration/Reactive/ReactiveConfigurationFactory.cs index 717a843..fc09949 100644 --- a/src/Cocoar.Configuration/Reactive/ReactiveConfigurationFactory.cs +++ b/src/Cocoar.Configuration/Reactive/ReactiveConfigurationFactory.cs @@ -136,6 +136,37 @@ private object CreateTupleReactiveConfig(Type tupleType) throw new InvalidOperationException($"Cannot create IReactiveConfig<{tupleType.Name}>. The following tuple element types are not configured/exposed: {string.Join(", ", invalid)}"); } + // In the GLOBAL pipeline (no tenant), a type whose EVERY rule is .TenantScoped() has no global value — + // its rules skip when there is no tenant. Surface that precisely instead of the generic "Missing + // configuration" from the tuple ctor; the fix is to read the tuple per tenant. (Mixed-scope tuples are + // otherwise fully supported: each element comes from this pipeline's snapshot.) + if (string.IsNullOrEmpty(accessor.Tenant)) + { + var tenantScopedOnly = new List(); + foreach (var et in elementTypes.Distinct()) + { + var concreteEt = et; + if (et.IsInterface && bindingRegistry.TryGetConcreteType(et, out var ct)) + { + concreteEt = ct; + } + + var typeRules = rules.Where(r => r.ConcreteType == concreteEt).ToList(); + if (typeRules.Count > 0 && typeRules.All(r => r.Options?.TenantScoped == true)) + { + tenantScopedOnly.Add(et.Name); + } + } + + if (tenantScopedOnly.Count > 0) + { + throw new InvalidOperationException( + $"Cannot create IReactiveConfig<{tupleType.Name}> in the global pipeline: type(s) " + + $"{string.Join(", ", tenantScopedOnly)} have only .TenantScoped() rules, so they have no " + + $"global value. Use GetReactiveConfigForTenant<{tupleType.Name}>(tenantId) instead."); + } + } + // Prime each distinct element type's reactive config foreach (var et in elementTypes.Distinct()) { diff --git a/src/tests/Cocoar.Configuration.MultiTenant.Tests/MixedScopeTupleTests.cs b/src/tests/Cocoar.Configuration.MultiTenant.Tests/MixedScopeTupleTests.cs new file mode 100644 index 0000000..3cdabd7 --- /dev/null +++ b/src/tests/Cocoar.Configuration.MultiTenant.Tests/MixedScopeTupleTests.cs @@ -0,0 +1,62 @@ +using Cocoar.Configuration.Core; +using Cocoar.Configuration.Providers; + +namespace Cocoar.Configuration.MultiTenant.Tests; + +/// +/// Tuples whose element types have different *rule scopes* are fully supported — "scope" is a property of a +/// rule (.TenantScoped()), not of a type. Each element is read from the relevant pipeline's atomic +/// snapshot: the global accessor skips tenant overlays; the per-tenant accessor gives effective values. The +/// only error case is a GLOBAL tuple containing a type whose EVERY rule is tenant-scoped (no global value). +/// +[Trait("Category", "MultiTenant")] +[Trait("Type", "Unit")] +public sealed class MixedScopeTupleTests +{ + public sealed class Gl { public string V { get; set; } = ""; } // only a global rule + public sealed class Mix { public string V { get; set; } = ""; } // global base + tenant overlay + public sealed class TenantOnly { public string V { get; set; } = ""; } // ONLY a .TenantScoped() rule + + private static ConfigManager Build() => ConfigManager.Create(c => c + .UseConfiguration(rules => + [ + rules.For().FromStaticJson("""{ "V": "global" }"""), + rules.For().FromStaticJson("""{ "V": "mix-base" }"""), + rules.For().FromStatic(x => new Mix { V = $"mix-{x.Tenant}" }).TenantScoped(), + rules.For().FromStatic(x => new TenantOnly { V = $"to-{x.Tenant}" }).TenantScoped(), + ]) + .UseDebounce(25)); + + [Fact] + public async Task PerTenant_MixedScope_ReadsGlobalAndTenantEffective() + { + using var mgr = Build(); + var tenants = (ITenantConfigurationAccessor)mgr; + await tenants.InitializeTenantAsync("acme"); + + var (g, m) = tenants.GetReactiveConfigForTenant<(Gl, Mix)>("acme").CurrentValue; + Assert.Equal("global", g.V); // global-only type → global value + Assert.Equal("mix-acme", m.V); // mixed type → tenant-effective value + } + + [Fact] + public void Global_MixedScope_SkipsTenantOverlay() + { + using var mgr = Build(); + + var (g, m) = mgr.GetReactiveConfig<(Gl, Mix)>().CurrentValue; + Assert.Equal("global", g.V); // global value + Assert.Equal("mix-base", m.V); // base value, tenant overlay skipped + } + + [Fact] + public void Global_WithTenantOnlyType_ThrowsTargetedError() + { + using var mgr = Build(); + + // A type with ONLY .TenantScoped() rules has no value in the global pipeline → targeted error. + var ex = Assert.Throws(() => mgr.GetReactiveConfig<(Gl, TenantOnly)>()); + Assert.Contains("only .TenantScoped() rules", ex.Message); + Assert.Contains("GetReactiveConfigForTenant", ex.Message); + } +} diff --git a/website/adr/ADR-005-multi-tenant-configuration.md b/website/adr/ADR-005-multi-tenant-configuration.md index bb1aec4..5e717d3 100644 --- a/website/adr/ADR-005-multi-tenant-configuration.md +++ b/website/adr/ADR-005-multi-tenant-configuration.md @@ -115,7 +115,7 @@ The tenant dimension is unified by the factory + bundle: - **Feature Flags / Entitlements** become tenant-aware **without a source-generator change**: the generated flag class already reads an injected `IReactiveConfig`; tenant-awareness means constructing it with the **tenant's** `IReactiveConfig`. `GetFeatureFlagsForTenant(id)` is a per-`(tenant, TFlags)` factory/cache over the existing generated class. The context-aware evaluator and the REST endpoints (`MapFeatureFlagEndpoints`) gain a tenant dimension (e.g. a route segment). - **WritableStore** per tenant: reads fall out of the factory (`FromStore(BackendFor(tenant))`; file backend = a folder per tenant); writes go through a per-tenant `GetWritableStoreForTenant(id)` facade pointing at the tenant's backend. -- **Secrets** are already tenant-capable via folder mode (`kid` = tenant subfolder routes decryption); a tenant writes its encrypted envelope to its own backend, decrypted with its own cert. +- **Secrets** are tenant-capable via folder mode (`kid` = tenant subfolder routes decryption); a tenant writes its encrypted envelope to its own backend, decrypted with its own cert. **Encryption-key publishing is per tenant too:** `GetCurrentKeyForTenant(tenantId)` / `MapTenantSecretEncryptionKey` return that tenant's **single current public key** — the newest cert in its subfolder (older certs stay decrypt-only for rotation) — resolved from `ITenantContext`, never a list and never another tenant's key. See [Publishing Encryption Keys](/guide/secrets/key-publishing). ### 8. No-DI core preserved @@ -160,7 +160,7 @@ Tenant methods live on a **new** `ITenantConfigurationAccessor` that `ConfigMana ✅ Captive-dependency class of bugs avoided by design (explicit `…ForTenant(id)` only) ⚠️ Structural rework of `ConfigManager`'s "one state per manager" ownership model (mechanical but not an additive extension; `ConfigManager` is sealed) — **done**: extracted into `TenantPipeline`, global path byte-identical -⚠️ Per-tenant eventual consistency (vs. ADR-002 global atomicity) — observable for tuples spanning a global-only and a tenant-scoped type; **mixed-scope tuples are not supported in v1** +⚠️ Per-tenant eventual consistency (vs. ADR-002 global atomicity) — a global base change lands tenant-by-tenant. Tuples stay atomic *within* a pipeline; mixed-scope tuples ARE supported (each element is read from one pipeline's snapshot), and a tuple's global-only element read per tenant is just an eventual-consistent copy — not a tuple-internal skew. ⚠️ Resource use scales linearly with initialized tenants × base rules (each tenant re-runs the global base); acceptable for a host-bounded active-tenant set, and the seed-from-global optimization can reclaim it later without an API change ⚠️ Each tenant holds its own subscription to live base sources — for an SSE/HTTP base that is N connections to the config server; document and revisit with seed-from-global if it bites at scale @@ -169,7 +169,7 @@ Tenant methods live on a **new** `ITenantConfigurationAccessor` that `ConfigMana ## Open questions (implementation-level) - **Fan-out throttle at scale:** with full-list-per-tenant, a global base change fans out as one independent debounced recompute per initialized tenant; whether a cross-pipeline throttle is needed depends on tenant/subscriber counts. Becomes pressing only if seed-from-global lands (a single coordinator then drives all tenants). -- **Mixed-scope tuples:** **decided — not supported in v1.** `IReactiveConfig<(Global, TenantScoped)>` / `IFeatureFlags<(Global, TenantScoped)>` would show transient skew; only same-scope tuples are supported. +- **Tuples across scopes:** **resolved — supported.** Each element is read from the relevant pipeline's atomic snapshot (global skips `.TenantScoped()` overlays; per-tenant is effective). A *global* tuple with a type whose every rule is `.TenantScoped()` throws (no global value) — read it per tenant. ("Scope" is a rule property, not a type property; the earlier "not supported" framing was wrong.) - **Idle-read freshness contract:** moot in v1 — idle initialized tenants self-update via their own subscriptions, so a sync read is current. (Re-opens only with seed-from-global's stale-mark model.) --- diff --git a/website/changelog.md b/website/changelog.md index c3fc283..2faa75e 100644 --- a/website/changelog.md +++ b/website/changelog.md @@ -21,7 +21,8 @@ - `ITenantConfigurationAccessor` lifecycle: `InitializeTenantAsync` / `EnsureTenantInitializedAsync` / `RemoveTenantAsync` - Per-tenant access: `GetConfigForTenant` / `GetReactiveConfigForTenant` / `GetFeatureFlagsForTenant` / `GetEntitlementsForTenant` / `GetWritableStoreForTenant` - Tenant-only types excluded from the global DI plan; per-tenant flags/entitlements need no source-generator change -- ASP.NET Core: scoped `ITenantReactiveConfig` + `ITenantContext`; `MapTenantFeatureFlagEndpoints()` / `MapTenantEntitlementEndpoints()` +- Tenant config consumption (DI, no ASP.NET dependency): scoped `ITenantReactiveConfig` + `ITenantContext`; `AddCocoarTenantResolver(s => s.TenantId)` resolves the current tenant from any DI service (HTTP via `IHttpContextAccessor`) — no hand-written adapter +- ASP.NET Core: `MapTenantFeatureFlagEndpoints()` / `MapTenantEntitlementEndpoints()` **Service-Backed (DI-aware) configuration** (ADR-006) - Two-layer model: eager `UseConfiguration` (Layer 1) + lazy `UseServiceBackedConfiguration` (Layer 2), whose provider factories receive the `IServiceProvider` @@ -30,8 +31,9 @@ - Public `ServiceBackedProviderBuilder` seam for third-party `(sp, a)` provider overloads **Secrets — encryption-key publishing** -- Publish the public half of the secrets encryption key (`ISecretEncryptionKeyProvider`; ASP.NET Core `MapSecretEncryptionKeyEndpoints()` at `/.well-known/cocoar/encryption-keys`) so a browser/CLI can build `cocoar.secret` envelopes -- `SecretEnvelope` typed secret-overlay writes; WritableStore `SetSecretAsync` / `SetSecretEnvelopeAsync` accept pre-encrypted envelopes +- Publish the public half of the secrets encryption key so a browser/CLI can build `cocoar.secret` envelopes — `ISecretEncryptionKeyProvider` (`GetCurrentKey()` / `GetCurrentKeyForTenant(tenantId)`) returns exactly one current public key (the newest cert; older certs stay decrypt-only) +- ASP.NET Core `MapSecretEncryptionKey()` (single-tenant) / `MapTenantSecretEncryptionKey()` (per-tenant; tenant from `ITenantContext`) at `/.well-known/cocoar/encryption-key` — one key per request, never a list, no cross-tenant exposure +- `SecretEnvelope` typed secret-overlay writes; WritableStore `SetSecretAsync` / `SetSecretEnvelopeAsync` accept pre-encrypted envelopes (per tenant via `GetWritableStoreForTenant(id).SetSecretAsync(...)`) **Custom-provider authoring** - Public `ProviderObservable` / `ProviderDisposable` helpers (in `Cocoar.Configuration.Providers.Abstractions`) for a provider's change stream without referencing System.Reactive diff --git a/website/guide/multi-tenancy/overview.md b/website/guide/multi-tenancy/overview.md index 4b9ad68..e49ed8c 100644 --- a/website/guide/multi-tenancy/overview.md +++ b/website/guide/multi-tenancy/overview.md @@ -57,13 +57,23 @@ var store = manager.GetWritableStoreForTenant("acme"); // per-t A type whose **every** rule is `.TenantScoped()` has no global value. Injecting it into a long-lived (singleton) consumer would be a captive-dependency bug — it would freeze one tenant forever, since the container cannot know the runtime tenant. The DI planner therefore **excludes** purely tenant-scoped types from the global plan. A type that *also* has a global base rule stays injectable (its base value is a valid global config). Consuming services inject the `ConfigManager` / `ITenantConfigurationAccessor` and call `…ForTenant(currentTenant)`. -### Scoped per-request injection (ASP.NET Core) +### Scoped per-request injection (DI) -So scoped/transient services don't have to thread the tenant id by hand, `Cocoar.Configuration.AspNetCore` offers a **scoped** `ITenantReactiveConfig` that resolves the *current request's* tenant for you. You supply a scoped `ITenantContext` (only your app knows where the tenant lives — a claim, header, or route value); the adapter delegates to `GetReactiveConfigForTenant(tenant)`. +So scoped/transient services don't have to thread the tenant id by hand, a **scoped** `ITenantReactiveConfig` (in `Cocoar.Configuration.DI`) resolves the *current* tenant for you. It reads the tenant from a scoped `ITenantContext` and delegates to `GetReactiveConfigForTenant(tenant)`. + +You don't hand-write an `ITenantContext` — point a **resolver** at whatever already knows the tenant: ```csharp -// Register the adapter + a default ITenantContext that reads the tenant from the request: -builder.Services.AddCocoarTenantReactiveConfig(http => http.Request.RouteValues["tenant"]?.ToString()); +// Register the scoped adapter: +builder.Services.AddCocoarTenantReactiveConfig(); + +// ...then point a resolver at your existing tenant service: +builder.Services.AddCocoarTenantResolver(s => s.TenantId); + +// ...or, for plain HTTP, at IHttpContextAccessor — no AspNetCore-specific API needed: +builder.Services.AddHttpContextAccessor(); +builder.Services.AddCocoarTenantResolver( + a => a.HttpContext?.Request.RouteValues["tenant"]?.ToString()); // Ensure the tenant pipeline is warm before it is consumed (e.g. request-start middleware): app.Use(async (ctx, next) => @@ -80,7 +90,9 @@ public sealed class SmtpSender(ITenantReactiveConfig smtp) } ``` -The singleton `IReactiveConfig` is **untouched** — it stays the global view, so singletons keep working. A singleton that needs a specific tenant still calls `GetReactiveConfigForTenant(id)` explicitly (it has no ambient request tenant). +The selector is re-evaluated on every access, so the tenant can become known after the scope starts (e.g. post-auth-middleware). The singleton `IReactiveConfig` is **untouched** — it stays the global view, so singletons keep working. A singleton that needs a specific tenant still calls `GetReactiveConfigForTenant(id)` explicitly (it has no ambient request tenant). + +Without DI there is no ambient scope to resolve from — pass the tenant explicitly with the `…ForTenant(id)` methods. ## Feature flags & entitlements per tenant @@ -134,8 +146,11 @@ A tenant decrypts its own secret with its own certificate; it cannot decrypt ano Each tenant pipeline runs the full rule list with its **own** provider subscriptions, so a change to a live global base source (file / observable / HTTP) propagates to every initialized tenant on its own debounced recompute and re-emits on that tenant's `IReactiveConfig`. A tenant that masks the changed key with its own override does not emit. No coordinator to configure; consistency is **per-tenant eventual** (a global change lands tenant-by-tenant as each rebuild finishes). +## Tuples across tenant scopes + +A `ValueTuple` mixing a global-only type and a tenant-overridable one is **fully supported** — each element is read from the relevant pipeline's atomic snapshot. The global accessor skips `.TenantScoped()` overlays (you get base values); the per-tenant accessor gives effective values. The same holds for tuple-typed `IFeatureFlags` / `IEntitlements`. The one case that errors is a *global* tuple containing a type whose **every** rule is `.TenantScoped()` — it has no global value, so read it per tenant (`GetReactiveConfigForTenant<…>(id)`). ("Scope" is a property of a rule, not of a type — a type can carry both global and tenant-scoped rules.) + ## Limits in this version -- **Mixed-scope tuples** — `IReactiveConfig<(Global, TenantScoped)>` / `IFeatureFlags<(Global, TenantScoped)>` are **not supported** (they would show transient skew during fan-out). Use same-scope tuples. - **Resource use** scales linearly with initialized tenants × base rules (each tenant re-runs the base). Acceptable for a host-bounded active-tenant set; a shared seed-from-global optimization is a future, API-compatible change. - **Eviction** is explicit (`RemoveTenantAsync`) only — no idle eviction. From 445e490366ea0cd96bcbcda948d0c51d0e7a34f1 Mon Sep 17 00:00:00 2001 From: Bernhard Windisch Date: Sun, 31 May 2026 11:08:04 +0200 Subject: [PATCH 11/18] fix: targeted error when reading a tenant-only type from the global pipeline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A type whose every rule is .TenantScoped() has no value in the global pipeline (its rules skip without a tenant). Single reads now throw a targeted error pointing at GetConfigForTenant/GetReactiveConfigForTenant — matching the existing tuple guard — instead of the misleading generic "no rule registered" message (a rule does exist; it's just tenant-scoped). The sync path is guarded in ConfigurationAccessor (which now receives the rule list); the reactive single-read path is guarded independently in ReactiveConfigurationFactory (it reads the backplane, not the accessor), throwing eagerly like the tuple guard. Covers GetConfig() and GetReactiveConfig(). MixedScopeTupleTests adds both cases. Co-Authored-By: Claude Sonnet 4.6 --- .../Core/ConfigurationAccessor.cs | 54 ++++++++++++++++--- .../Core/TenantPipeline.cs | 2 +- .../Reactive/ReactiveConfigurationFactory.cs | 22 ++++++++ .../MixedScopeTupleTests.cs | 29 ++++++++++ 4 files changed, 100 insertions(+), 7 deletions(-) diff --git a/src/Cocoar.Configuration/Core/ConfigurationAccessor.cs b/src/Cocoar.Configuration/Core/ConfigurationAccessor.cs index 8db513b..d5e0a8e 100644 --- a/src/Cocoar.Configuration/Core/ConfigurationAccessor.cs +++ b/src/Cocoar.Configuration/Core/ConfigurationAccessor.cs @@ -1,6 +1,7 @@ using System.Text.Json; using Cocoar.Capabilities; using Cocoar.Configuration.Infrastructure; +using Cocoar.Configuration.Rules; using Cocoar.Configuration.Utilities; using Cocoar.Json.Mutable; using Microsoft.Extensions.Logging; @@ -23,17 +24,20 @@ internal partial class ConfigurationAccessor : IConfigurationAccessor private readonly ConfigurationState _state; private readonly ExposureRegistry _bindingRegistry; private readonly ILogger _logger; + private readonly List _rules; private ConfigManagerCapabilityScope? _capabilityScope; public ConfigurationAccessor( ConfigurationState state, ExposureRegistry bindingRegistry, ILogger logger, + List rules, string? tenant = null) { _state = state; _bindingRegistry = bindingRegistry; _logger = logger; + _rules = rules; Tenant = tenant; } @@ -86,9 +90,7 @@ private T FallbackDeserialize() if (!_state.TryGetConfiguration(targetType, out var json) || json == null) { - throw new InvalidOperationException( - $"No configuration rule is registered for type '{type.Name}'. " + - $"Add a rule using: rules.For<{type.Name}>().From..."); + throw NoConfigurationFor(type, targetType); } _logger.FallbackDeserialization(type.Name); @@ -128,6 +130,48 @@ private T FallbackDeserialize() } } + /// + /// Builds the exception thrown when no committed configuration exists for a requested type. In the global + /// (tenant-agnostic) pipeline a type whose EVERY rule is .TenantScoped() contributes nothing — its + /// rules skip when there is no tenant — so it genuinely has no global value. Surface that precisely (point + /// the caller at the per-tenant API) instead of the generic "add a rule" message, which is misleading when + /// a rule does exist. Mirrors the tuple guard in . + /// + private InvalidOperationException NoConfigurationFor(Type requestedType, Type targetType) + { + if (string.IsNullOrWhiteSpace(Tenant) && HasOnlyTenantScopedRules(targetType)) + { + return new InvalidOperationException( + $"Configuration type '{requestedType.Name}' has only .TenantScoped() rules, so it has no global " + + $"value. Read it per tenant with GetConfigForTenant<{requestedType.Name}>(tenantId) or " + + $"GetReactiveConfigForTenant<{requestedType.Name}>(tenantId)."); + } + + return new InvalidOperationException( + $"No configuration rule is registered for type '{requestedType.Name}'. " + + $"Add a rule using: rules.For<{requestedType.Name}>().From..."); + } + + private bool HasOnlyTenantScopedRules(Type type) + { + var hasRule = false; + foreach (var rule in _rules) + { + if (rule.ConcreteType != type) + { + continue; + } + + hasRule = true; + if (rule.Options?.TenantScoped != true) + { + return false; + } + } + + return hasRule; + } + public bool TryGetConfig(out T? value) where T : class { try @@ -191,9 +235,7 @@ private object FallbackDeserialize(Type type) if (!_state.TryGetConfiguration(targetType, out var json) || json == null) { - throw new InvalidOperationException( - $"No configuration rule is registered for type '{type.Name}'. " + - $"Add a rule using: rules.For<{type.Name}>().From..."); + throw NoConfigurationFor(type, targetType); } _logger.FallbackDeserialization(type.Name); diff --git a/src/Cocoar.Configuration/Core/TenantPipeline.cs b/src/Cocoar.Configuration/Core/TenantPipeline.cs index 67696b8..bbef4a8 100644 --- a/src/Cocoar.Configuration/Core/TenantPipeline.cs +++ b/src/Cocoar.Configuration/Core/TenantPipeline.cs @@ -71,7 +71,7 @@ internal TenantPipeline( // interpolate it) in a tenant pipeline, while the global pipeline keeps tenant == null. State = new ConfigurationState(RuleManagers, Rules, _logger, flagsHealthSource); ProviderRegistry = new ProviderRegistry(_logger, enableDiagnostics: false, factory: providerFactory); - Accessor = new ConfigurationAccessor(State, _bindingRegistry, _logger, tenantId); + Accessor = new ConfigurationAccessor(State, _bindingRegistry, _logger, Rules, tenantId); Accessor.SetCapabilityScope(_capabilityScope); ReactiveConfigManager = new ReactiveConfigManager(_logger, _bindingRegistry); diff --git a/src/Cocoar.Configuration/Reactive/ReactiveConfigurationFactory.cs b/src/Cocoar.Configuration/Reactive/ReactiveConfigurationFactory.cs index fc09949..e685f25 100644 --- a/src/Cocoar.Configuration/Reactive/ReactiveConfigurationFactory.cs +++ b/src/Cocoar.Configuration/Reactive/ReactiveConfigurationFactory.cs @@ -47,6 +47,28 @@ public IReactiveConfig GetReactiveConfig(Func configAccessor) return (IReactiveConfig)CreateTupleReactiveConfig(t); } + // In the GLOBAL pipeline (no tenant), a type whose EVERY rule is .TenantScoped() has no global value — + // its rules skip when there is no tenant. Surface that precisely (point at the per-tenant API) instead + // of the generic "No configuration available" thrown later by the backplane reader. Mirrors the tuple + // guard in CreateTupleReactiveConfig and the sync guard in ConfigurationAccessor.NoConfigurationFor. + if (string.IsNullOrEmpty(accessor.Tenant)) + { + var concrete = t; + if (t.IsInterface && bindingRegistry.TryGetConcreteType(t, out var ct)) + { + concrete = ct; + } + + var typeRules = rules.Where(r => r.ConcreteType == concrete).ToList(); + if (typeRules.Count > 0 && typeRules.All(r => r.Options?.TenantScoped == true)) + { + throw new InvalidOperationException( + $"Cannot create IReactiveConfig<{t.Name}> in the global pipeline: type {t.Name} has only " + + $".TenantScoped() rules, so it has no global value. " + + $"Use GetReactiveConfigForTenant<{t.Name}>(tenantId) instead."); + } + } + // For interfaces, look up the concrete type from the binding registry if (t.IsInterface) { diff --git a/src/tests/Cocoar.Configuration.MultiTenant.Tests/MixedScopeTupleTests.cs b/src/tests/Cocoar.Configuration.MultiTenant.Tests/MixedScopeTupleTests.cs index 3cdabd7..d3bf763 100644 --- a/src/tests/Cocoar.Configuration.MultiTenant.Tests/MixedScopeTupleTests.cs +++ b/src/tests/Cocoar.Configuration.MultiTenant.Tests/MixedScopeTupleTests.cs @@ -59,4 +59,33 @@ public void Global_WithTenantOnlyType_ThrowsTargetedError() Assert.Contains("only .TenantScoped() rules", ex.Message); Assert.Contains("GetReactiveConfigForTenant", ex.Message); } + + [Fact] + public void Global_SingleRead_OfTenantOnlyType_ThrowsTargetedError() + { + using var mgr = Build(); + + // A single type with ONLY .TenantScoped() rules contributes nothing to the global pipeline (its rules + // skip without a tenant), so a global read has no value — same targeted error as the tuple case, not + // the generic "no rule registered" (a rule does exist; it's just tenant-scoped). + var ex = Assert.Throws(() => mgr.GetConfig()); + Assert.Contains("only .TenantScoped() rules", ex.Message); + Assert.Contains("ForTenant", ex.Message); + } + + [Fact] + public void Global_SingleReactiveRead_OfTenantOnlyType_ThrowsTargetedError() + { + using var mgr = Build(); + + // The reactive single-read goes through the backplane (not the sync accessor), so it is guarded + // independently in the reactive factory — same targeted message, thrown eagerly like the tuple guard. + var ex = Assert.Throws(() => + { + var reactive = mgr.GetReactiveConfig(); + _ = reactive.CurrentValue; + }); + Assert.Contains("only .TenantScoped() rules", ex.Message); + Assert.Contains("GetReactiveConfigForTenant", ex.Message); + } } From b4305ecbccccb67da99cefcb0cb35b17e06a6471 Mon Sep 17 00:00:00 2001 From: Bernhard Windisch Date: Sun, 31 May 2026 11:10:41 +0200 Subject: [PATCH 12/18] test: concurrency proof for per-tenant WritableStore isolation Two tests closing the isolation/atomicity-under-concurrency gap from the review: (1) concurrent writes across 8 tenants stay isolated while readers continuously assert no torn snapshot (Host always inherits the base; Port is only ever the base or that tenant's own value, never another's); (2) a write storm on a single tenant serializes through the store lock without deadlock or corruption and converges to a valid written value. Co-Authored-By: Claude Sonnet 4.6 --- .../TenantWritableStoreConcurrencyTests.cs | 146 ++++++++++++++++++ 1 file changed, 146 insertions(+) create mode 100644 src/tests/Cocoar.Configuration.MultiTenant.Tests/TenantWritableStoreConcurrencyTests.cs diff --git a/src/tests/Cocoar.Configuration.MultiTenant.Tests/TenantWritableStoreConcurrencyTests.cs b/src/tests/Cocoar.Configuration.MultiTenant.Tests/TenantWritableStoreConcurrencyTests.cs new file mode 100644 index 0000000..7a932dd --- /dev/null +++ b/src/tests/Cocoar.Configuration.MultiTenant.Tests/TenantWritableStoreConcurrencyTests.cs @@ -0,0 +1,146 @@ +using System.Collections.Concurrent; +using System.Linq; +using Cocoar.Configuration.Core; +using Cocoar.Configuration.WritableStore; +using Cocoar.Configuration.Providers; // FromObservable / FromStore / GetWritableStoreForTenant + +namespace Cocoar.Configuration.MultiTenant.Tests; + +/// +/// Concurrency proof for the per-tenant WritableStore: writes on one tenant never bleed into another, and a +/// read of any tenant — even while other tenants are being hammered with writes — is always a whole, consistent +/// snapshot (the sparse overlay merges atomically; Host always inherits the base, Port is only ever this +/// tenant's own value or the base). Closes the isolation/atomicity-under-concurrency gap noted in the review. +/// Reuses InMemoryBackend (defined in TenantWritableStoreTests) so each tenant gets its own file-free store. +/// +[Trait("Category", "MultiTenant")] +[Trait("Type", "Concurrency")] +public class TenantWritableStoreConcurrencyTests +{ + public sealed record Smtp + { + public string Host { get; init; } = ""; + public int Port { get; init; } + } + + private static ConfigManager Build() + { + var backends = new ConcurrentDictionary(); + return ConfigManager.Create(c => c + .UseConfiguration(rules => + [ + rules.For().FromObservable("""{ "Host": "smtp.default.com", "Port": 25 }"""), + rules.For().FromStore((a, _) => backends.GetOrAdd(a.Tenant ?? "", _ => new InMemoryBackend())) + .TenantScoped(), + ]) + .UseDebounce(25)); + } + + [Fact] + public async Task ConcurrentWritesAcrossTenants_StayIsolated_AndReadsAreNeverTorn() + { + const int tenantCount = 8; + const int writesPerTenant = 40; + + using var mgr = Build(); + var tenants = (ITenantConfigurationAccessor)mgr; + + // Tenant ti always writes Port = i (a fixed per-tenant value), so ordering is irrelevant and the only + // legal values a reader of ti may ever observe are 25 (base) or i (ti's own overlay). + var ids = Enumerable.Range(0, tenantCount).Select(i => $"t{i}").ToArray(); + foreach (var id in ids) + { + await tenants.InitializeTenantAsync(id); + } + + using var cts = new CancellationTokenSource(); + var readerErrors = new ConcurrentQueue(); + + var readers = ids.Select(id => + { + var own = int.Parse(id[1..]); + return Task.Run(async () => + { + while (!cts.IsCancellationRequested) + { + var cfg = mgr.GetConfigForTenant(id); + if (cfg is not null) + { + // Host is never overridden → must always be the inherited base (no torn snapshot). + if (cfg.Host != "smtp.default.com") + { + readerErrors.Enqueue($"{id}: torn Host '{cfg.Host}'"); + } + // Port is either the base or THIS tenant's own value — never another tenant's. + if (cfg.Port != 25 && cfg.Port != own) + { + readerErrors.Enqueue($"{id}: cross-tenant Port {cfg.Port} (legal: 25 or {own})"); + } + } + + await Task.Yield(); + } + }); + }).ToArray(); + + var writers = ids.Select(id => + { + var value = int.Parse(id[1..]); + var store = mgr.GetWritableStoreForTenant(id); + return Task.Run(async () => + { + for (var w = 0; w < writesPerTenant; w++) + { + await store.SetAsync(x => x.Port, value); + } + }); + }).ToArray(); + + await Task.WhenAll(writers); + + foreach (var id in ids) + { + var own = int.Parse(id[1..]); + await TenantWait.UntilAsync(() => mgr.GetConfigForTenant(id)?.Port == own, $"{id} converged to {own}"); + } + + cts.Cancel(); + await Task.WhenAll(readers); + + Assert.True(readerErrors.IsEmpty, string.Join(" | ", readerErrors)); + + // Final state: each tenant holds its own value, Host inherited, nothing corrupted. + foreach (var id in ids) + { + var cfg = mgr.GetConfigForTenant(id)!; + Assert.Equal(int.Parse(id[1..]), cfg.Port); + Assert.Equal("smtp.default.com", cfg.Host); + } + } + + [Fact] + public async Task ConcurrentWritesToSameTenant_SerializeWithoutCorruptionOrDeadlock() + { + const int writes = 60; + + using var mgr = Build(); + var tenants = (ITenantConfigurationAccessor)mgr; + await tenants.InitializeTenantAsync("solo"); + + var store = mgr.GetWritableStoreForTenant("solo"); + + // Hammer one tenant's overlay with concurrent writes of distinct values — must serialize through the + // store's write lock without throwing, deadlocking, or corrupting the persisted JSON. + var tasks = Enumerable.Range(1, writes).Select(v => store.SetAsync(x => x.Port, v)); + await Task.WhenAll(tasks); + + // Converges to SOME written value; Host stays inherited; the overlay remains a valid sparse override. + await TenantWait.UntilAsync( + () => mgr.GetConfigForTenant("solo")?.Port is >= 1 and <= writes, + "solo converged to a written value"); + + var cfg = mgr.GetConfigForTenant("solo")!; + Assert.InRange(cfg.Port, 1, writes); + Assert.Equal("smtp.default.com", cfg.Host); + } +} From 54185b162cf8c7a6ad3c69fb0faf85ec4e25cf05 Mon Sep 17 00:00:00 2001 From: Bernhard Windisch Date: Sun, 31 May 2026 11:13:27 +0200 Subject: [PATCH 13/18] docs: add MultiTenancyExample example project MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Runnable, self-contained example of the core multi-tenancy mental model: one flat rule list with a global base + a .TenantScoped() overlay whose file path is interpolated from accessor.Tenant. Shows sparse per-tenant inheritance — global reads the base (overlay skipped), each tenant overrides only the keys it sets and inherits the rest. Listed in the Examples README under a new Multi-tenancy section. Co-Authored-By: Claude Sonnet 4.6 --- .../MultiTenancyExample.csproj | 13 ++++ src/Examples/MultiTenancyExample/Program.cs | 62 +++++++++++++++++++ src/Examples/README.md | 3 + 3 files changed, 78 insertions(+) create mode 100644 src/Examples/MultiTenancyExample/MultiTenancyExample.csproj create mode 100644 src/Examples/MultiTenancyExample/Program.cs diff --git a/src/Examples/MultiTenancyExample/MultiTenancyExample.csproj b/src/Examples/MultiTenancyExample/MultiTenancyExample.csproj new file mode 100644 index 0000000..a23932b --- /dev/null +++ b/src/Examples/MultiTenancyExample/MultiTenancyExample.csproj @@ -0,0 +1,13 @@ + + + Exe + net9.0 + enable + enable + true + Examples.MultiTenancyExample + + + + + diff --git a/src/Examples/MultiTenancyExample/Program.cs b/src/Examples/MultiTenancyExample/Program.cs new file mode 100644 index 0000000..153ef54 --- /dev/null +++ b/src/Examples/MultiTenancyExample/Program.cs @@ -0,0 +1,62 @@ +using Cocoar.Configuration.Core; +using Cocoar.Configuration.Providers; // FromStaticJson / FromFile + +namespace Examples.MultiTenancyExample; + +// Demonstrates MULTI-TENANCY: the SAME configuration type resolves to a DIFFERENT value per tenant, +// layered on a shared global base. You author ONE flat rule list: +// - global rules apply to everyone and form the base each tenant inherits +// - rules marked .TenantScoped() run ONLY inside a tenant pipeline; the tenant id flows in via accessor.Tenant +// - each tenant's overlay is SPARSE — it overrides only the keys it sets and inherits the rest +public static class Program +{ + public sealed record Branding + { + public string ProductName { get; init; } = ""; + public string Theme { get; init; } = ""; + public string SupportEmail { get; init; } = ""; + } + + public static async Task Main() + { + // Self-contained demo: write two tiny SPARSE per-tenant override files next to the binary. + var tenantDir = Path.Combine(AppContext.BaseDirectory, "tenants"); + Directory.CreateDirectory(tenantDir); + await File.WriteAllTextAsync( + Path.Combine(tenantDir, "contoso.json"), + """{ "Theme": "contoso-dark", "SupportEmail": "support@contoso.example" }"""); + await File.WriteAllTextAsync( + Path.Combine(tenantDir, "globex.json"), + """{ "Theme": "globex-neon" }"""); // overrides ONLY Theme — everything else inherits the base + + using var manager = ConfigManager.Create(c => c.UseConfiguration(rules => + [ + // GLOBAL base — applies to everyone; tenants inherit any key they don't override. + rules.For().FromStaticJson( + """{ "ProductName": "Acme Cloud", "Theme": "light", "SupportEmail": "support@acme.example" }"""), + + // TENANT overlay — ONE rule serves every tenant: the tenant id flows into the file path via + // accessor.Tenant. Skipped entirely in the global pipeline (there is no tenant there). + rules.For().FromFile(a => $"tenants/{a.Tenant}.json").TenantScoped(), + ])); + + // Tenants are explicit and host-owned: initialize, then read synchronously. + var tenants = (ITenantConfigurationAccessor)manager; + await tenants.InitializeTenantAsync("contoso"); + await tenants.InitializeTenantAsync("globex"); + + // GLOBAL read: tenant overlays are skipped → the pure base. + Print("global (no tenant)", manager.GetConfig()!); + + // PER-TENANT reads: the overlay wins per key, the rest inherits the base. + Print("contoso", manager.GetConfigForTenant("contoso")!); // Theme + SupportEmail overridden + Print("globex", manager.GetConfigForTenant("globex")!); // only Theme overridden; the rest inherited + + Console.WriteLine(); + Console.WriteLine("ProductName is inherited from the global base by every tenant (sparse overlay);"); + Console.WriteLine("globex inherits SupportEmail too — it only overrode Theme."); + } + + private static void Print(string label, Branding b) => + Console.WriteLine($"{label,-18} ProductName={b.ProductName,-12} Theme={b.Theme,-14} Support={b.SupportEmail}"); +} diff --git a/src/Examples/README.md b/src/Examples/README.md index bbebf51..08b75f4 100644 --- a/src/Examples/README.md +++ b/src/Examples/README.md @@ -20,6 +20,9 @@ This directory contains runnable examples for **Cocoar.Configuration**. Each sub - **AggregateRules** – Composable rule grouping (`FromFiles` sugar) with byte-level JSON merge - **TupleReactiveExample** – Tuple-based reactive multi-config snapshot & aligned emission demo +### Multi-tenancy +- **MultiTenancyExample** – The same type resolves to a different value per tenant: one flat rule list with a global base + a `.TenantScoped()` overlay whose file path is interpolated from `accessor.Tenant`; sparse per-tenant inheritance + ### DI & ASP.NET Core - **ExposeExample** – Interface exposure without DI frameworks - **AspNetCoreExample** – Minimal API exposing configuration via endpoints From 793e09a6b0801288b6dbc3d5f629807cbbfdd02a Mon Sep 17 00:00:00 2001 From: Bernhard Windisch Date: Sun, 31 May 2026 11:15:24 +0200 Subject: [PATCH 14/18] docs: stamp changelog 5.1.0 (2026-05-31) + targeted tenant-only-read error Promote the [Unreleased] section to [5.1.0] in both CHANGELOG.md and the published docs changelog, and add the targeted-error change (reading a tenant-only type from the global pipeline) under Changed. Co-Authored-By: Claude Sonnet 4.6 --- CHANGELOG.md | 3 ++- website/changelog.md | 5 ++++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9695a1d..e3bf68a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## [Unreleased] +## [5.1.0] - 2026-05-31 ### Added @@ -36,6 +36,7 @@ ### Changed - Secret payloads (the decrypted value of `Secret`) now (de)serialize with lenient options: **enums as names** (round-trip-safe if the enum is later reordered) and **case-insensitive** property matching. Reading still accepts numeric enums and any casing, so **existing encrypted secrets remain fully readable** — no migration. Only the in-memory form of newly serialized typed secret values changes (enum name instead of ordinal); encrypted envelopes at rest are unaffected. +- Reading a tenant-only type (every rule `.TenantScoped()`) from the global pipeline now throws a **targeted** error pointing at `GetConfigForTenant(id)` / `GetReactiveConfigForTenant(id)` — for both `GetConfig()` and `GetReactiveConfig()` — instead of the misleading generic "no configuration rule is registered" message (a rule does exist; it is just tenant-scoped). Matches the existing mixed-scope-tuple guard. ### Notes diff --git a/website/changelog.md b/website/changelog.md index 2faa75e..70a3ea6 100644 --- a/website/changelog.md +++ b/website/changelog.md @@ -1,6 +1,6 @@ # Changelog -## [Unreleased] +## [5.1.0] — 2026-05-31 ### Added @@ -46,6 +46,9 @@ - Reading still accepts numeric enums and any casing → **existing encrypted secrets remain fully readable**, no migration. - Recommendation: when encrypting an enum secret with the CLI, pass the **name** (e.g. `Active`) rather than the ordinal. +**Multi-tenancy — clearer error for a tenant-only type read globally** +- Reading a type whose every rule is `.TenantScoped()` from the global pipeline now throws a **targeted** error pointing at `GetConfigForTenant(id)` / `GetReactiveConfigForTenant(id)` — for both `GetConfig()` and `GetReactiveConfig()` — instead of the generic "no rule registered" message (a rule does exist; it is just tenant-scoped). + ## [5.0.0] — 2026-03-24 ### Added From e7bbc034bbf1e3da12639e51b5d11b9116871fed Mon Sep 17 00:00:00 2001 From: Bernhard Windisch Date: Sun, 31 May 2026 11:58:34 +0200 Subject: [PATCH 15/18] feat: @cocoar/secrets TypeScript client (browser-encrypted secrets) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a pnpm + Turborepo TypeScript monorepo under src/ts/ (ready for a future @cocoar/flags package) with its first package, @cocoar/secrets@0.1.0: the browser/Node client that encrypts a secret with the server's published public key so the server stores only an encrypted envelope and never sees the plaintext. - Lib (zero runtime deps, WebCrypto): encryptSecret + fetchEncryptionKey + PublishedKey/SecretEnvelope types + base64url helpers, coded against the verified server contract (RSA-OAEP-SHA256 key wrap + AES-256-GCM, base64url no padding, JSON-serialized plaintext). - vitest unit tests (5): envelope shape, string/object round-trip, fresh IV per call, unsupported-algorithm rejection. - tsup build → ESM + CJS + .d.ts. - Cross-language acceptance (golden fixture): a TS-produced envelope is committed with a throwaway test key and decrypted by a .NET xunit test (Cocoar.Configuration.Secrets.Tests/CrossLang) — proves TS→.NET wire compatibility, runs in the normal suite with no Node dependency. Regenerate via the secrets package's gen:fixtures script. - Browser demo (demo/index.html) — verified end-to-end in a real browser. The TS client versions and publishes to npm independently of the .NET (NuGet) packages; it is not part of the 5.1.0 .NET release. Co-Authored-By: Claude Sonnet 4.6 --- .../Cocoar.Configuration.Secrets.Tests.csproj | 5 + .../CrossLang/TsEnvelopeCrossLangTests.cs | 69 + .../CrossLang/ts-envelope.fixture.json | 25 + src/ts/.gitignore | 4 + src/ts/README.md | 22 + src/ts/package.json | 14 + src/ts/pnpm-lock.yaml | 1662 +++++++++++++++++ src/ts/pnpm-workspace.yaml | 10 + src/ts/secrets/README.md | 55 + src/ts/secrets/demo/index.html | 131 ++ src/ts/secrets/package.json | 51 + .../scripts/gen-crosslang-fixtures.mjs | 61 + src/ts/secrets/src/index.ts | 165 ++ src/ts/secrets/tests/encrypt.test.ts | 95 + src/ts/secrets/tsconfig.json | 8 + src/ts/secrets/tsup.config.ts | 9 + src/ts/secrets/vitest.config.ts | 8 + src/ts/tsconfig.base.json | 12 + src/ts/turbo.json | 15 + 19 files changed, 2421 insertions(+) create mode 100644 src/tests/Cocoar.Configuration.Secrets.Tests/CrossLang/TsEnvelopeCrossLangTests.cs create mode 100644 src/tests/Cocoar.Configuration.Secrets.Tests/CrossLang/ts-envelope.fixture.json create mode 100644 src/ts/.gitignore create mode 100644 src/ts/README.md create mode 100644 src/ts/package.json create mode 100644 src/ts/pnpm-lock.yaml create mode 100644 src/ts/pnpm-workspace.yaml create mode 100644 src/ts/secrets/README.md create mode 100644 src/ts/secrets/demo/index.html create mode 100644 src/ts/secrets/package.json create mode 100644 src/ts/secrets/scripts/gen-crosslang-fixtures.mjs create mode 100644 src/ts/secrets/src/index.ts create mode 100644 src/ts/secrets/tests/encrypt.test.ts create mode 100644 src/ts/secrets/tsconfig.json create mode 100644 src/ts/secrets/tsup.config.ts create mode 100644 src/ts/secrets/vitest.config.ts create mode 100644 src/ts/tsconfig.base.json create mode 100644 src/ts/turbo.json diff --git a/src/tests/Cocoar.Configuration.Secrets.Tests/Cocoar.Configuration.Secrets.Tests.csproj b/src/tests/Cocoar.Configuration.Secrets.Tests/Cocoar.Configuration.Secrets.Tests.csproj index 359c63a..53e9e38 100644 --- a/src/tests/Cocoar.Configuration.Secrets.Tests/Cocoar.Configuration.Secrets.Tests.csproj +++ b/src/tests/Cocoar.Configuration.Secrets.Tests/Cocoar.Configuration.Secrets.Tests.csproj @@ -26,4 +26,9 @@ + + + + + diff --git a/src/tests/Cocoar.Configuration.Secrets.Tests/CrossLang/TsEnvelopeCrossLangTests.cs b/src/tests/Cocoar.Configuration.Secrets.Tests/CrossLang/TsEnvelopeCrossLangTests.cs new file mode 100644 index 0000000..b1c17b7 --- /dev/null +++ b/src/tests/Cocoar.Configuration.Secrets.Tests/CrossLang/TsEnvelopeCrossLangTests.cs @@ -0,0 +1,69 @@ +using System.Security.Cryptography; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Cocoar.Configuration.Secrets.Tests.CrossLang; + +/// +/// Cross-language acceptance: an envelope produced by the @cocoar/secrets TypeScript client (committed +/// as a golden fixture) must decrypt on the .NET side to the original value. Proves the wire format is +/// byte-compatible across stacks — RSA-OAEP-SHA256 key wrap + AES-256-GCM, base64url (no padding), and a +/// JSON-serialized plaintext (a string becomes a quoted JSON string). The decryption mirrors +/// CertificateInventory.TryDecryptWithCert. Regenerate: pnpm --filter @cocoar/secrets gen:fixtures. +/// +[Trait("Category", "Secrets")] +[Trait("Type", "Unit")] +public class TsEnvelopeCrossLangTests +{ + private sealed record Fixture( + [property: JsonPropertyName("plaintext")] string Plaintext, + [property: JsonPropertyName("privateKeyPkcs8")] string PrivateKeyPkcs8, + [property: JsonPropertyName("envelope")] Envelope Envelope); + + private sealed record Envelope( + [property: JsonPropertyName("kid")] string Kid, + [property: JsonPropertyName("wk")] string Wk, + [property: JsonPropertyName("iv")] string Iv, + [property: JsonPropertyName("ct")] string Ct, + [property: JsonPropertyName("tag")] string Tag); + + [Fact] + public void TypeScriptEnvelope_DecryptsOnDotNet_ToOriginalValue() + { + var path = Path.Combine(AppContext.BaseDirectory, "CrossLang", "ts-envelope.fixture.json"); + Assert.True( + File.Exists(path), + $"Cross-language fixture missing at {path}. Regenerate with: pnpm --filter @cocoar/secrets gen:fixtures"); + + var fixture = JsonSerializer.Deserialize(File.ReadAllText(path)) + ?? throw new InvalidOperationException("Fixture could not be parsed."); + var envelope = fixture.Envelope; + + using var rsa = RSA.Create(); + rsa.ImportPkcs8PrivateKey(Convert.FromBase64String(fixture.PrivateKeyPkcs8), out _); + + // 1. RSA-OAEP-SHA256 unwrap the one-time AES key. + var dek = rsa.Decrypt(FromBase64Url(envelope.Wk), RSAEncryptionPadding.OaepSHA256); + + // 2. AES-256-GCM open (ciphertext + tag, 96-bit IV, no AAD) — exactly what the server does. + var iv = FromBase64Url(envelope.Iv); + var ciphertext = FromBase64Url(envelope.Ct); + var tag = FromBase64Url(envelope.Tag); + var plaintext = new byte[ciphertext.Length]; + using (var aes = new AesGcm(dek, tag.Length)) + { + aes.Decrypt(iv, ciphertext, tag, plaintext); + } + + // The plaintext is the value JSON-serialized; a string round-trips as a quoted JSON string. + var value = JsonSerializer.Deserialize(plaintext); + Assert.Equal(fixture.Plaintext, value); + } + + private static byte[] FromBase64Url(string value) + { + var b64 = value.Replace('-', '+').Replace('_', '/'); + b64 += (b64.Length % 4) switch { 2 => "==", 3 => "=", _ => string.Empty }; + return Convert.FromBase64String(b64); + } +} diff --git a/src/tests/Cocoar.Configuration.Secrets.Tests/CrossLang/ts-envelope.fixture.json b/src/tests/Cocoar.Configuration.Secrets.Tests/CrossLang/ts-envelope.fixture.json new file mode 100644 index 0000000..786d8c5 --- /dev/null +++ b/src/tests/Cocoar.Configuration.Secrets.Tests/CrossLang/ts-envelope.fixture.json @@ -0,0 +1,25 @@ +{ + "_comment": "TEST-ONLY cross-language fixture (TS encrypt -> .NET decrypt). privateKeyPkcs8 is a throwaway test key, never used in production. Regenerate: pnpm --filter @cocoar/secrets gen:fixtures (from src/ts).", + "plaintext": "cross-language-secret-✓", + "privateKeyPkcs8": "MIIEvwIBADANBgkqhkiG9w0BAQEFAASCBKkwggSlAgEAAoIBAQDLUACNWe9v+3Ba5r1yNY5kjZVPX2E2I4Yr2INl3grvI3BVzVWh3sO0yCJVoMDHV3NydDzWMoyP+5od+ord4DjHLBiAPbFU8OD4e3jgcvMtp0gmWE4PzLuIlmrIPpuyR+Zgxeyc8iep5dMyF//GWXyvg/vs+uYQFtfckVWwRLe9jNw6cFJMUgD+elapJf9UEJEvav/WA6+So9SHyZ6Aaursk962pP4YHTyu4N4qmNq8xYsd7gje83aXrhbI984xGB5E133uKUWMIsvjTDER6vJrwnzG4oaV6/oaNXt5z/RUAIlLnX6LPjnTjX6KZFf0t0mbx7O4S58XE0k1GTmlZZElAgMBAAECggEAWvrW9GoNcpAS7CqbFhOPP8H30cf9yWtGLDK8QgaF4BN8vnifXEtCSpDQhH7mJY4ltE79tmBydDAD/GxUXgtrfP1njWOOClOsaKVgJxhpZ8GW0MntTLayKOIesLMLm1D/b8jCJE1coHOe+aixpYIAAM7QGnEAKK0ANcy3iqSFkIU+9xF3ewz/wJNCNvQtGxyCr+PkxxnHnOkwPHwTl+yPuO5/3NR6mULcO9Kbx+TrSx/do1hiilmm6bPV6uu+xugr0F04mpAMgd0V4rEpg4QkLz/yr3+kTFZmpcuZY8+prmVpEdj9zdUEvxz8INHvi8YNU0mC9tYNumYaqrmG1Kuk1QKBgQDq1aNMwEVYgNFaYcYq+l8BVY4EDiWRb78TJWfrXcLFftBxpJVwHF7A35AN8F7uVEzbD1uGeuV67fmeVQg7IIoBGGrSLKcdwDhLJC0HKXfRVd13Fvl8ivieGDL08yqz77/M1O6WhFcX1z9gE/jRILhS4VWWvI0N9t9iQpRg2qG5VwKBgQDdow2utq/r24QSWl2V6H9K3qaEN67r8kPibVEI/JQxxrYo1zA3I6ZX23EdXxDgmN+LoxTNPuSWS/2tMlJbM3sfKvdnWO1rMfRnap+Bt6ggZrd7loXnahVnO1l71n/XwTM8oa654KCGukV71w9aMJJ6GWKvxRUPJ0Za4XpZe/rv4wKBgQC5u8Lun1u5hhkO/Sn5MBEYQwyR1IaOIu3jPMCWfY/hGE3QtDWleCrRLYEIJUdQBMjMsbbYBDegJLw7KFHcQf4ko6VJGhwLAc8ET1Z1xayImvsQh9FHWbsNf2I+Ll1l94ehgAA8d+tSZeIwIVu4O8LNen9xOyvtEYbYdoRYnlMu/QKBgQCmgX+e+Qoy4+hVrWPPYip3QcvRWs/j3E6ZhcyyNqbSullRV1CWrAakzGwvR6MzSEXw7qSLCSvLqe07N6ndcFCX+ryA/LLorE/Tfau1VRdOuDwx8cRJNU49Dr5MJbpHiZKHg2PzqsiOTtQylD+pXwI8DmQwbpxClRvq3KgBH/5GRwKBgQCUqUA2c4NXujFHtOJaoaePoux7KixprsHnxzvXTyC3DJz5rcRP3rTnZRH+1+ZKjfcthB7ZdsbsoNhx4RhWjq+d4CKnDZ4Ntdv4ov5Cidf9rOMu9EL4Q6aOV6YHd6FQG3dCI5DvKP29GjlOTEPGSHJiiB7hRYAXxdTpjxRaVrWTAA==", + "publishedKey": { + "kid": "crosslang-test", + "alg": "RSA-OAEP-AES256-GCM", + "walg": "RSA-OAEP-256", + "enc": "AES-256-GCM", + "format": "spki", + "encoding": "base64url", + "publicKey": "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAy1AAjVnvb_twWua9cjWOZI2VT19hNiOGK9iDZd4K7yNwVc1Vod7DtMgiVaDAx1dzcnQ81jKMj_uaHfqK3eA4xywYgD2xVPDg-Ht44HLzLadIJlhOD8y7iJZqyD6bskfmYMXsnPInqeXTMhf_xll8r4P77PrmEBbX3JFVsES3vYzcOnBSTFIA_npWqSX_VBCRL2r_1gOvkqPUh8megGrq7JPetqT-GB08ruDeKpjavMWLHe4I3vN2l64WyPfOMRgeRNd97ilFjCLL40wxEerya8J8xuKGlev6GjV7ec_0VACJS51-iz45041-imRX9LdJm8ezuEufFxNJNRk5pWWRJQIDAQAB" + }, + "envelope": { + "type": "cocoar.secret", + "version": 1, + "kid": "crosslang-test", + "alg": "RSA-OAEP-AES256-GCM", + "wk": "igJIKNk57cccYpyX0y-TFezpx939oXvYE-uxEGYiP89DCeN4HMJ4-qasEaynPY-gERnjesV0M71AUsV6YR_l2UUwFhhqrv8A3aQEo49tLin3h9WNwjvsoq-etgMAIt2YJJtKyBgGlFfCRBvgfPgOMrCc9XwX70kNSOEO_A3g5j32XNtm9Y9xaIzWzgA9aGu_I2pNjH13GDw6ZMvsSnuD2wIYZuv485UTWYfouyn8t_W6sppaf551oVV3WqG6ROP0cXT3AvMO6mQnaP8HRys8hUP6oiwOr2oBJwujJaNwKf1mTMb48EljU4DFI0mOrXu1y3qb-T0Ehi5sIgtPE0Z0vw", + "walg": "RSA-OAEP-256", + "iv": "bkOOW8_zpTatqn5e", + "ct": "WFUrzfIXHh8tMoxjkodw70Fskk4Yn7pDAlVw", + "tag": "jEy5IpSMULhgy5QoT7wm8w" + } +} diff --git a/src/ts/.gitignore b/src/ts/.gitignore new file mode 100644 index 0000000..4b5fe1b --- /dev/null +++ b/src/ts/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +dist/ +.turbo/ +*.tsbuildinfo diff --git a/src/ts/README.md b/src/ts/README.md new file mode 100644 index 0000000..72bd43e --- /dev/null +++ b/src/ts/README.md @@ -0,0 +1,22 @@ +# Cocoar.Configuration — TypeScript clients + +A pnpm + Turborepo monorepo for the publishable TypeScript client libraries that pair with +**Cocoar.Configuration**. Each package versions and publishes to npm independently. + +## Packages + +| Package | npm | Status | +|---------|-----|--------| +| [`secrets/`](./secrets) | `@cocoar/secrets` | encrypt secrets client-side so the server stores only an encrypted envelope and never sees the plaintext | +| `flags/` | `@cocoar/flags` | feature flags & entitlements client — _planned_ | + +## Develop + +```bash +# from src/ts +pnpm install +pnpm build # turbo run build across all packages +pnpm test # turbo run test across all packages +``` + +Adding a package: create `src/ts//` and add it to `pnpm-workspace.yaml`. diff --git a/src/ts/package.json b/src/ts/package.json new file mode 100644 index 0000000..c3fb107 --- /dev/null +++ b/src/ts/package.json @@ -0,0 +1,14 @@ +{ + "name": "cocoar-configuration-ts", + "private": true, + "packageManager": "pnpm@11.2.2", + "description": "TypeScript client libraries for Cocoar.Configuration (pnpm + Turborepo monorepo).", + "scripts": { + "build": "turbo run build", + "test": "turbo run test", + "dev": "turbo run dev" + }, + "devDependencies": { + "turbo": "^2.3.3" + } +} diff --git a/src/ts/pnpm-lock.yaml b/src/ts/pnpm-lock.yaml new file mode 100644 index 0000000..28b2dd4 --- /dev/null +++ b/src/ts/pnpm-lock.yaml @@ -0,0 +1,1662 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + devDependencies: + turbo: + specifier: ^2.3.3 + version: 2.9.16 + + secrets: + devDependencies: + tsup: + specifier: ^8.0.0 + version: 8.5.1(postcss@8.5.15)(typescript@5.9.3) + typescript: + specifier: ^5.7.0 + version: 5.9.3 + vitest: + specifier: ^4.1.0 + version: 4.1.7(vite@8.0.14(esbuild@0.27.7)) + +packages: + + '@emnapi/core@1.10.0': + resolution: {integrity: sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==} + + '@emnapi/runtime@1.10.0': + resolution: {integrity: sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==} + + '@emnapi/wasi-threads@1.2.1': + resolution: {integrity: sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==} + + '@esbuild/aix-ppc64@0.27.7': + resolution: {integrity: sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.27.7': + resolution: {integrity: sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.27.7': + resolution: {integrity: sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.27.7': + resolution: {integrity: sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.27.7': + resolution: {integrity: sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.27.7': + resolution: {integrity: sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.27.7': + resolution: {integrity: sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.27.7': + resolution: {integrity: sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.27.7': + resolution: {integrity: sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.27.7': + resolution: {integrity: sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.27.7': + resolution: {integrity: sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.27.7': + resolution: {integrity: sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.27.7': + resolution: {integrity: sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.27.7': + resolution: {integrity: sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.27.7': + resolution: {integrity: sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.27.7': + resolution: {integrity: sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.27.7': + resolution: {integrity: sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.27.7': + resolution: {integrity: sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.27.7': + resolution: {integrity: sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.27.7': + resolution: {integrity: sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.27.7': + resolution: {integrity: sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.27.7': + resolution: {integrity: sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.27.7': + resolution: {integrity: sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.27.7': + resolution: {integrity: sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.27.7': + resolution: {integrity: sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.27.7': + resolution: {integrity: sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@jridgewell/gen-mapping@0.3.13': + resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + + '@napi-rs/wasm-runtime@1.1.4': + resolution: {integrity: sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==} + peerDependencies: + '@emnapi/core': ^1.7.1 + '@emnapi/runtime': ^1.7.1 + + '@oxc-project/types@0.132.0': + resolution: {integrity: sha512-FESMOxil5Se014ui/Eq8fT5uHJo6nIRwH0PfJrZJXs6Gek3ZVFOrpUv3YIZT20m+extU98Hg1Ym72U58rlsxUQ==} + + '@rolldown/binding-android-arm64@1.0.2': + resolution: {integrity: sha512-ZS4D1JPGn/MYQN/SYDWftIE/nVsM8j/AFOYEzAoOE2O3NktQOZru+/vYXGbR/qtdLdIfGCP0lcoJiYVzsEz+iQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@rolldown/binding-darwin-arm64@1.0.2': + resolution: {integrity: sha512-vdFA9+C/rekyGce7WqHs/xoT0ioZEWaOFyZLIV1mEeNFaFDUQrPIo8Vs2GvJ6eetb3rzDUtUBgzto3ExpXJB3w==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@rolldown/binding-darwin-x64@1.0.2': + resolution: {integrity: sha512-BewSOwTHazv77DTYiAZXSqqKZ4KP/KonFisDMVU7PImxoWfB2aepnPhd2E4SWz3zDzYgDNbs6jBmTdgNnF02GA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@rolldown/binding-freebsd-x64@1.0.2': + resolution: {integrity: sha512-m41o7M0YWtUdqk61Tb+jnKb2rN++iRdIASlExkUoKfIAH30DOHCB8fVLzSUpbWHHU8esmEioY62PxzexE8MBuA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@rolldown/binding-linux-arm-gnueabihf@1.0.2': + resolution: {integrity: sha512-jcojB9H7W/jS29pMKWAK1N+fU99vXodHDTatS3b3y/XSOCiHo0kkA74pL3jJmkoQtYpOCxDvaKs1fo2Ij/1X5w==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@rolldown/binding-linux-arm64-gnu@1.0.2': + resolution: {integrity: sha512-1jn6qDU5iiOgFgygDzKUuKP0maTi0/f1+sBLgvij/76C77Nm3ts6ufz9Bjg5q5dduxiUIxtq86JIoBvo1xQ4Ig==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-arm64-musl@1.0.2': + resolution: {integrity: sha512-QVLO/czFMdoMFSqlX3bcswcJNm/23r+qoa/jgtmFc/qEp6/jXmIkDjF/XIo8dPfGaiwy1xfQn8o77L79GeXFgw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rolldown/binding-linux-ppc64-gnu@1.0.2': + resolution: {integrity: sha512-hgO5Abm0w5UL6FEa2iFnZqo2KlK7TQ5QhV5x09hujBf7t5KzHQ1VmfPuTpqRy/rNlSxua3eWH374xxiVrP+lcA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-s390x-gnu@1.0.2': + resolution: {integrity: sha512-fy8rXxuYEu602abC8MUNaPjYLIFzReOaEIEMKMUa0rFEUxNpVXhs15KSSQ4qlqSaM7B6rcj9rDZgADh/IGDzLQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-x64-gnu@1.0.2': + resolution: {integrity: sha512-0+bOkiQ779+r1WpoHOWHqncvyySci0vKph+myNDYb+im6meJAzHQXay6oEgnkHuUGouM1LKTZwqKpBow6Kj7CQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-x64-musl@1.0.2': + resolution: {integrity: sha512-mjSkrzZK5Qsl0a9d1JgILOiuZOSDTVdKENcSXBoqbzSrspLR/4/IRVDo5wd2GgZjNss/viBFJdeq+j7qH2nypw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rolldown/binding-openharmony-arm64@1.0.2': + resolution: {integrity: sha512-1v5vHasdfQAZoEHakBV72LIFAC9JjnymsiKxp+GEr/ma3+NJCPSaYK+qavInOovJkgwFrs7GccX2d6IgDA3Z5w==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@rolldown/binding-wasm32-wasi@1.0.2': + resolution: {integrity: sha512-mb1VobWn6NheziTk5/WEaR6AKVbrwT5sOi6C7zk3gy/pD1qtJfU1j4PgTo2NJnOtbL9Dl3Aeei8w9jJ7qC2jZQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [wasm32] + + '@rolldown/binding-win32-arm64-msvc@1.0.2': + resolution: {integrity: sha512-SqKonF56vA/L2yHwHYcEp2P34URpOZ7d1fS635cTkpDnUtEGdUbhI6NzsPdqeSWvAAeGDrxjWjNmibDIdFf9/A==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@rolldown/binding-win32-x64-msvc@1.0.2': + resolution: {integrity: sha512-v7qRI7gXLRINcOGXt+7YmAZ6iFuyZVMIoXAxhd8oP+DR9dLfL9GfNIx7PLMxmhZdvq8waUJBQiWN9EKNy+TRBQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@rolldown/pluginutils@1.0.1': + resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==} + + '@rollup/rollup-android-arm-eabi@4.60.4': + resolution: {integrity: sha512-F5QXMSiFebS9hKZj02XhWLLnRpJ3B3AROP0tWbFBSj+6kCbg5m9j5JoHKd4mmSVy5mS/IMQloYgYxCuJC0fxEQ==} + cpu: [arm] + os: [android] + + '@rollup/rollup-android-arm64@4.60.4': + resolution: {integrity: sha512-GxxTKApUpzRhof7poWvCJHRF51C67u1R7D6DiluBE8wKU1u5GWE8t+v81JvJYtbawoBFX1hLv5Ei4eVjkWokaw==} + cpu: [arm64] + os: [android] + + '@rollup/rollup-darwin-arm64@4.60.4': + resolution: {integrity: sha512-tua0TaJxMOB1R0V0RS1jFZ/RpURFDJIOR2A6jWwQeawuFyS4gBW+rntLRaQd0EQ4bd6Vp44Z2rXW+YYDBsj6IA==} + cpu: [arm64] + os: [darwin] + + '@rollup/rollup-darwin-x64@4.60.4': + resolution: {integrity: sha512-CSKq7MsP+5PFIcydhAiR1K0UhEI1A2jWXVKHPCBZ151yOutENwvnPocgVHkivu2kviURtCEB6zUQw0vs8RrhMg==} + cpu: [x64] + os: [darwin] + + '@rollup/rollup-freebsd-arm64@4.60.4': + resolution: {integrity: sha512-+O8OkVdyvXMtJEciu2wS/pzm1IxntEEQx3z5TAVy4l32G0etZn+RsA48ARRrFm6Ri8fvqPQfgrvNxSjKAbnd3g==} + cpu: [arm64] + os: [freebsd] + + '@rollup/rollup-freebsd-x64@4.60.4': + resolution: {integrity: sha512-Iw3oMskH3AfNuhU0MSN7vNbdi4me/NiYo2azqPz/Le16zHSa+3RRmliCMWWQmh4lcndccU40xcJuTYJZxNo/lw==} + cpu: [x64] + os: [freebsd] + + '@rollup/rollup-linux-arm-gnueabihf@4.60.4': + resolution: {integrity: sha512-EIPRXTVQpHyF8WOo219AD2yEltPehLTcTMz2fn6JsatLYSzQf00hj3rulF+yauOlF9/FtM2WpkT/hJh/KJFGhA==} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm-musleabihf@4.60.4': + resolution: {integrity: sha512-J3Yh9PzzF1Ovah2At+lHiGQdsYgArxBbXv/zHfSyaiFQEqvNv7DcW98pCrmdjCZBrqBiKrKKe2V+aaSGWuBe/w==} + cpu: [arm] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-arm64-gnu@4.60.4': + resolution: {integrity: sha512-BFDEZMYfUvLn37ONE1yMBojPxnMlTFsdyNoqncT0qFq1mAfllL+ATMMJd8TeuVMiX84s1KbcxcZbXInmcO2mRg==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm64-musl@4.60.4': + resolution: {integrity: sha512-pc9EYOSlOgdQ2uPl1o9PF6/kLSgaUosia7gOuS8mB69IxJvlclko1MECXysjs5ryez1/5zjYqx3+xYU0TU6R1A==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-loong64-gnu@4.60.4': + resolution: {integrity: sha512-NxnomyxYerDh5n4iLrNa+sH+Z+U4BMEE46V2PgQ/hoB909i8gV1M5wPojWg9fk1jWpO3IQnOs20K4wyZuFLEFQ==} + cpu: [loong64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-loong64-musl@4.60.4': + resolution: {integrity: sha512-nbJnQ8a3z1mtmrwImCYhc6BGpThAyYVRQxw9uKSKG4wR6aAYno9sVjJ0zaZcW9BPJX1GbrDPf+SvdWjgTuDmnw==} + cpu: [loong64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-ppc64-gnu@4.60.4': + resolution: {integrity: sha512-2EU6acNrQLd8tYvo/LXW535wupT3m6fo7HKo6lr7ktQoItxTyOL1ZCR/GfGCuXl2vR+zmfI6eRXkSemafv+iVg==} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-ppc64-musl@4.60.4': + resolution: {integrity: sha512-WeBtoMuaMxiiIrO2IYP3xs6GMWkJP2C0EoT8beTLkUPmzV1i/UcOSVw1d5r9KBODtHKilG5yFxsGRnBbK3wJ4A==} + cpu: [ppc64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-riscv64-gnu@4.60.4': + resolution: {integrity: sha512-FJHFfqpKUI3A10WrWKiFbBZ7yVbGT4q4B5o1qKFFojqpaYoh9LrQgqWCmmcxQzVSXYtyB5bzkXrYzlHTs21MYA==} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-riscv64-musl@4.60.4': + resolution: {integrity: sha512-mcEl6CUT5IAUmQf1m9FYSmVqCJlpQ8r8eyftFUHG8i9OhY7BkBXSUdnLH5DOf0wCOjcP9v/QO93zpmF1SptCCw==} + cpu: [riscv64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-s390x-gnu@4.60.4': + resolution: {integrity: sha512-ynt3JxVd2w2buzoKDWIyiV1pJW93xlQic1THVLXilz429oijRpSHivZAgp65KBu+cMcgf1eVVjdnTLvPxgCuoQ==} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-gnu@4.60.4': + resolution: {integrity: sha512-Boiz5+MsaROEWDf+GGEwF8VMHGhlUoQMtIPjOgA5fv4osupqTVnJteQNKJwUcnUog2G55jYXH7KZFFiJe0TEzQ==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-musl@4.60.4': + resolution: {integrity: sha512-+qfSY27qIrFfI/Hom04KYFw3GKZSGU4lXus51wsb5EuySfFlWRwjkKWoE9emgRw/ukoT4Udsj4W/+xxG8VbPKg==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rollup/rollup-openbsd-x64@4.60.4': + resolution: {integrity: sha512-VpTfOPHgVXEBeeR8hZ2O0F3aSso+JDWqTWmTmzcQKted54IAdUVbxE+j/MVxUsKa8L20HJhv3vUezVPoquqWjA==} + cpu: [x64] + os: [openbsd] + + '@rollup/rollup-openharmony-arm64@4.60.4': + resolution: {integrity: sha512-IPOsh5aRYuLv/nkU51X10Bf75Bsf6+gZdx1X+QP5QM6lIJFHHqbHLG0uJn/hWthzo13UAc2umiUorqZy3axoZg==} + cpu: [arm64] + os: [openharmony] + + '@rollup/rollup-win32-arm64-msvc@4.60.4': + resolution: {integrity: sha512-4QzE9E81OohJ/HKzHhsqU+zcYYojVOXlFMs1DdyMT6qXl/niOH7AVElmmEdUNHHS/oRkc++d5k6Vy85zFs0DEw==} + cpu: [arm64] + os: [win32] + + '@rollup/rollup-win32-ia32-msvc@4.60.4': + resolution: {integrity: sha512-zTPgT1YuHHcd+Tmx7h8aml0FWFVelV5N54oHow9SLj+GfoDy/huQ+UV396N/C7KpMDMiPspRktzM1/0r1usYEA==} + cpu: [ia32] + os: [win32] + + '@rollup/rollup-win32-x64-gnu@4.60.4': + resolution: {integrity: sha512-DRS4G7mi9lJxqEDezIkKCaUIKCrLUUDCUaCsTPCi/rtqaC6D/jjwslMQyiDU50Ka0JKpeXeRBFBAXwArY52vBw==} + cpu: [x64] + os: [win32] + + '@rollup/rollup-win32-x64-msvc@4.60.4': + resolution: {integrity: sha512-QVTUovf40zgTqlFVrKA1uXMVvU2QWEFWfAH8Wdc48IxLvrJMQVMBRjuQyUpzZCDkakImib9eVazbWlC6ksWtJw==} + cpu: [x64] + os: [win32] + + '@standard-schema/spec@1.1.0': + resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + + '@turbo/darwin-64@2.9.16': + resolution: {integrity: sha512-jLjApWTSNd7JZ5JaLYfelW1ytnGQOvB7ivl+2RD1xQvJTbi8I9gBjzcga7tDZVPyaxpl10YTfJt3BrYXR18KDw==} + cpu: [x64] + os: [darwin] + + '@turbo/darwin-arm64@2.9.16': + resolution: {integrity: sha512-YPgrn+5HIGzrx0O2a631SV4MBQUe4W/DafMFUuBVgaU32PW9/OTT0ehviF0QSxTXuRJlHvW2eUTemddF5/spmw==} + cpu: [arm64] + os: [darwin] + + '@turbo/linux-64@2.9.16': + resolution: {integrity: sha512-vAEf1H6l26lTpl9FJ/peQo1NUB8RC0sbEJJz5mPcUhHA2bPDup2x3CZPgo/bH8S4cUcBLm4FN3UHd5iUO2RAew==} + cpu: [x64] + os: [linux] + + '@turbo/linux-arm64@2.9.16': + resolution: {integrity: sha512-xDBLR2PZg4BrQOchfG6svgpv5FCNJ2TOtT2psLdEJcdKo1BH+pnPs9Xj6pvUjgfkHbuvBOfeE4R6tvxMoQKDHQ==} + cpu: [arm64] + os: [linux] + + '@turbo/windows-64@2.9.16': + resolution: {integrity: sha512-NBAJnaUiGdgkSzQwUIdOvkCkcpTSu58G/sBGa0mvBtzfvFOOgrQwepKOOQ8cp6sWM6OcKDNFj2p1dsZA1OWjPg==} + cpu: [x64] + os: [win32] + + '@turbo/windows-arm64@2.9.16': + resolution: {integrity: sha512-Y7SJppD0Z8wjO3Ec0ZGd9KQ4Yv0BMnA8CIowj5Vp+OEVsosXDG2weK6/t1RRLfJmc2Ozrnd6y4DOgQys+mn3WQ==} + cpu: [arm64] + os: [win32] + + '@tybys/wasm-util@0.10.2': + resolution: {integrity: sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==} + + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + + '@types/estree@1.0.8': + resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} + + '@types/estree@1.0.9': + resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + + '@vitest/expect@4.1.7': + resolution: {integrity: sha512-1R+tw0ortHEbZDGMymm+pN7/AFQ/RkFFdtd7EN+VBpynKmLbP8A3rpEXdshBJ7+8hQ9zBJh/i1s0yKNtxAnU7w==} + + '@vitest/mocker@4.1.7': + resolution: {integrity: sha512-vY7nuamKgfvpA1Koa3oYIw/k7D6kZnpGyNMZW8loow2bsBYla1TFdqTaXncWdRn4pgwNs+90RhnXhJScDwQeJA==} + peerDependencies: + msw: ^2.4.9 + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@4.1.7': + resolution: {integrity: sha512-umgCarTOYQWIaDMvGDRZij+6b9oVeLIyJzfN+AS88e0ZOU3QTgNNSTtjQOpcvWr3np1N0j4WgZj+sb3oYBDscw==} + + '@vitest/runner@4.1.7': + resolution: {integrity: sha512-BapjmAQ2aI78WdMEfeUWivnfVzB+VPGwWRQcJE0OUq7qEeEcBsCSf+0T5iREBNE5nBb4wA5Ya0W6IA+sghdEFw==} + + '@vitest/snapshot@4.1.7': + resolution: {integrity: sha512-ZacLzja+TmJeZ1h14xW2FB/WpeimUD3haBXQPyJqxvo8jQTmfeA8zv58mtjN2C7EHXZDYVcVYdYmAxjkWVvKCw==} + + '@vitest/spy@4.1.7': + resolution: {integrity: sha512-kbkI5LMWakyuTIvs6fUJ5qdIVb1XVKsYJAT4OJ938cHMROYMSfmoQdZy0aaAnjbbc8F61vkoTqz/Az+/HiIu5Q==} + + '@vitest/utils@4.1.7': + resolution: {integrity: sha512-T532WBu791cBxJlCl6SO+J14l81DQx6uQHm1bQbmCDY7nqlEIgkza/UFnSBNaUtSf41unldDFjdOBYEQC4b5Hw==} + + acorn@8.16.0: + resolution: {integrity: sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==} + engines: {node: '>=0.4.0'} + hasBin: true + + any-promise@1.3.0: + resolution: {integrity: sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==} + + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + + bundle-require@5.1.0: + resolution: {integrity: sha512-3WrrOuZiyaaZPWiEt4G3+IffISVC9HYlWueJEBWED4ZH4aIAC2PnkdnuRrR94M+w6yGWn4AglWtJtBI8YqvgoA==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + peerDependencies: + esbuild: '>=0.18' + + cac@6.7.14: + resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} + engines: {node: '>=8'} + + chai@6.2.2: + resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} + engines: {node: '>=18'} + + chokidar@4.0.3: + resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==} + engines: {node: '>= 14.16.0'} + + commander@4.1.1: + resolution: {integrity: sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==} + engines: {node: '>= 6'} + + confbox@0.1.8: + resolution: {integrity: sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==} + + consola@3.4.2: + resolution: {integrity: sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==} + engines: {node: ^14.18.0 || >=16.10.0} + + convert-source-map@2.0.0: + resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + + es-module-lexer@2.1.0: + resolution: {integrity: sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==} + + esbuild@0.27.7: + resolution: {integrity: sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==} + engines: {node: '>=18'} + hasBin: true + + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + + expect-type@1.3.0: + resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} + engines: {node: '>=12.0.0'} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + fix-dts-default-cjs-exports@1.0.1: + resolution: {integrity: sha512-pVIECanWFC61Hzl2+oOCtoJ3F17kglZC/6N94eRWycFgBH35hHx0Li604ZIzhseh97mf2p0cv7vVrOZGoqhlEg==} + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + joycon@3.1.1: + resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==} + engines: {node: '>=10'} + + lightningcss-android-arm64@1.32.0: + resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [android] + + lightningcss-darwin-arm64@1.32.0: + resolution: {integrity: sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [darwin] + + lightningcss-darwin-x64@1.32.0: + resolution: {integrity: sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [darwin] + + lightningcss-freebsd-x64@1.32.0: + resolution: {integrity: sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [freebsd] + + lightningcss-linux-arm-gnueabihf@1.32.0: + resolution: {integrity: sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==} + engines: {node: '>= 12.0.0'} + cpu: [arm] + os: [linux] + + lightningcss-linux-arm64-gnu@1.32.0: + resolution: {integrity: sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + lightningcss-linux-arm64-musl@1.32.0: + resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [musl] + + lightningcss-linux-x64-gnu@1.32.0: + resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [glibc] + + lightningcss-linux-x64-musl@1.32.0: + resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [musl] + + lightningcss-win32-arm64-msvc@1.32.0: + resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [win32] + + lightningcss-win32-x64-msvc@1.32.0: + resolution: {integrity: sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [win32] + + lightningcss@1.32.0: + resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==} + engines: {node: '>= 12.0.0'} + + lilconfig@3.1.3: + resolution: {integrity: sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==} + engines: {node: '>=14'} + + lines-and-columns@1.2.4: + resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} + + load-tsconfig@0.2.5: + resolution: {integrity: sha512-IXO6OCs9yg8tMKzfPZ1YmheJbZCiEsnBdcB03l0OcfK9prKnJb96siuHCr5Fl37/yo9DnKU+TLpxzTUspw9shg==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + + mlly@1.8.2: + resolution: {integrity: sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA==} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + mz@2.7.0: + resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==} + + nanoid@3.3.12: + resolution: {integrity: sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + object-assign@4.1.1: + resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} + engines: {node: '>=0.10.0'} + + obug@2.1.1: + resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==} + + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@4.0.4: + resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} + engines: {node: '>=12'} + + pirates@4.0.7: + resolution: {integrity: sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==} + engines: {node: '>= 6'} + + pkg-types@1.3.1: + resolution: {integrity: sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==} + + postcss-load-config@6.0.1: + resolution: {integrity: sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==} + engines: {node: '>= 18'} + peerDependencies: + jiti: '>=1.21.0' + postcss: '>=8.0.9' + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + jiti: + optional: true + postcss: + optional: true + tsx: + optional: true + yaml: + optional: true + + postcss@8.5.15: + resolution: {integrity: sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==} + engines: {node: ^10 || ^12 || >=14} + + readdirp@4.1.2: + resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==} + engines: {node: '>= 14.18.0'} + + resolve-from@5.0.0: + resolution: {integrity: sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==} + engines: {node: '>=8'} + + rolldown@1.0.2: + resolution: {integrity: sha512-oZx5zVDtVB44AW3eaifgDml1gWRDZGvjcfdxonE4swNPG98PrrXjaO/KrnUjzlMnztCCRVlUueA1kCXhARGk6g==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + + rollup@4.60.4: + resolution: {integrity: sha512-WHeFSbZYsPu3+bLoNRUuAO+wavNlocOPf3wSHTP7hcFKVnJeWsYlCDbr3mTS14FCizf9ccIxXA8sGL8zKeQN3g==} + engines: {node: '>=18.0.0', npm: '>=8.0.0'} + hasBin: true + + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + source-map@0.7.6: + resolution: {integrity: sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==} + engines: {node: '>= 12'} + + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + + std-env@4.1.0: + resolution: {integrity: sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==} + + sucrase@3.35.1: + resolution: {integrity: sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==} + engines: {node: '>=16 || 14 >=14.17'} + hasBin: true + + thenify-all@1.6.0: + resolution: {integrity: sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==} + engines: {node: '>=0.8'} + + thenify@3.3.1: + resolution: {integrity: sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==} + + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + + tinyexec@0.3.2: + resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==} + + tinyexec@1.2.3: + resolution: {integrity: sha512-g62dB+w1/OEFnPvmX0yd/HnetYITOL+1nJW7kitOycOeAvmbWC/nu0fwmmQ/kupNojqExzyC/T++pST/jRJ2mQ==} + engines: {node: '>=18'} + + tinyglobby@0.2.16: + resolution: {integrity: sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==} + engines: {node: '>=12.0.0'} + + tinyrainbow@3.1.0: + resolution: {integrity: sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==} + engines: {node: '>=14.0.0'} + + tree-kill@1.2.2: + resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==} + hasBin: true + + ts-interface-checker@0.1.13: + resolution: {integrity: sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==} + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + + tsup@8.5.1: + resolution: {integrity: sha512-xtgkqwdhpKWr3tKPmCkvYmS9xnQK3m3XgxZHwSUjvfTjp7YfXe5tT3GgWi0F2N+ZSMsOeWeZFh7ZZFg5iPhing==} + engines: {node: '>=18'} + hasBin: true + peerDependencies: + '@microsoft/api-extractor': ^7.36.0 + '@swc/core': ^1 + postcss: ^8.4.12 + typescript: '>=4.5.0' + peerDependenciesMeta: + '@microsoft/api-extractor': + optional: true + '@swc/core': + optional: true + postcss: + optional: true + typescript: + optional: true + + turbo@2.9.16: + resolution: {integrity: sha512-NqgRQy6j6dPYcdSdv0q1g9QsZg7SWg87RERM8otw/1AtKU2yTFVClOM7cbwKzOonZr/Ek1blTBucw64L9H0Bwg==} + hasBin: true + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + + ufo@1.6.4: + resolution: {integrity: sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==} + + vite@8.0.14: + resolution: {integrity: sha512-s4BJJ+5y1pYL6Otw51FHhVJQhPnuRinKig64g/1+EUNaJsd3gCKdD31IPFvswUgW9/60QT9oFHbZHbQK5imcxw==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + '@vitejs/devtools': ^0.1.18 + esbuild: ^0.27.0 || ^0.28.0 + jiti: '>=1.21.0' + less: ^4.0.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + '@vitejs/devtools': + optional: true + esbuild: + optional: true + jiti: + optional: true + less: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + vitest@4.1.7: + resolution: {integrity: sha512-flYyaFd2CgoCoU+0UKt3pxksgC+S02iTDN0n3LtqaMeXsI9SBcdNujc2k0DeFLzUn/0k538yNjOSdwgCqcrwJA==} + engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@opentelemetry/api': ^1.9.0 + '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 + '@vitest/browser-playwright': 4.1.7 + '@vitest/browser-preview': 4.1.7 + '@vitest/browser-webdriverio': 4.1.7 + '@vitest/coverage-istanbul': 4.1.7 + '@vitest/coverage-v8': 4.1.7 + '@vitest/ui': 4.1.7 + happy-dom: '*' + jsdom: '*' + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@opentelemetry/api': + optional: true + '@types/node': + optional: true + '@vitest/browser-playwright': + optional: true + '@vitest/browser-preview': + optional: true + '@vitest/browser-webdriverio': + optional: true + '@vitest/coverage-istanbul': + optional: true + '@vitest/coverage-v8': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + +snapshots: + + '@emnapi/core@1.10.0': + dependencies: + '@emnapi/wasi-threads': 1.2.1 + tslib: 2.8.1 + optional: true + + '@emnapi/runtime@1.10.0': + dependencies: + tslib: 2.8.1 + optional: true + + '@emnapi/wasi-threads@1.2.1': + dependencies: + tslib: 2.8.1 + optional: true + + '@esbuild/aix-ppc64@0.27.7': + optional: true + + '@esbuild/android-arm64@0.27.7': + optional: true + + '@esbuild/android-arm@0.27.7': + optional: true + + '@esbuild/android-x64@0.27.7': + optional: true + + '@esbuild/darwin-arm64@0.27.7': + optional: true + + '@esbuild/darwin-x64@0.27.7': + optional: true + + '@esbuild/freebsd-arm64@0.27.7': + optional: true + + '@esbuild/freebsd-x64@0.27.7': + optional: true + + '@esbuild/linux-arm64@0.27.7': + optional: true + + '@esbuild/linux-arm@0.27.7': + optional: true + + '@esbuild/linux-ia32@0.27.7': + optional: true + + '@esbuild/linux-loong64@0.27.7': + optional: true + + '@esbuild/linux-mips64el@0.27.7': + optional: true + + '@esbuild/linux-ppc64@0.27.7': + optional: true + + '@esbuild/linux-riscv64@0.27.7': + optional: true + + '@esbuild/linux-s390x@0.27.7': + optional: true + + '@esbuild/linux-x64@0.27.7': + optional: true + + '@esbuild/netbsd-arm64@0.27.7': + optional: true + + '@esbuild/netbsd-x64@0.27.7': + optional: true + + '@esbuild/openbsd-arm64@0.27.7': + optional: true + + '@esbuild/openbsd-x64@0.27.7': + optional: true + + '@esbuild/openharmony-arm64@0.27.7': + optional: true + + '@esbuild/sunos-x64@0.27.7': + optional: true + + '@esbuild/win32-arm64@0.27.7': + optional: true + + '@esbuild/win32-ia32@0.27.7': + optional: true + + '@esbuild/win32-x64@0.27.7': + optional: true + + '@jridgewell/gen-mapping@0.3.13': + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 + + '@napi-rs/wasm-runtime@1.1.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)': + dependencies: + '@emnapi/core': 1.10.0 + '@emnapi/runtime': 1.10.0 + '@tybys/wasm-util': 0.10.2 + optional: true + + '@oxc-project/types@0.132.0': {} + + '@rolldown/binding-android-arm64@1.0.2': + optional: true + + '@rolldown/binding-darwin-arm64@1.0.2': + optional: true + + '@rolldown/binding-darwin-x64@1.0.2': + optional: true + + '@rolldown/binding-freebsd-x64@1.0.2': + optional: true + + '@rolldown/binding-linux-arm-gnueabihf@1.0.2': + optional: true + + '@rolldown/binding-linux-arm64-gnu@1.0.2': + optional: true + + '@rolldown/binding-linux-arm64-musl@1.0.2': + optional: true + + '@rolldown/binding-linux-ppc64-gnu@1.0.2': + optional: true + + '@rolldown/binding-linux-s390x-gnu@1.0.2': + optional: true + + '@rolldown/binding-linux-x64-gnu@1.0.2': + optional: true + + '@rolldown/binding-linux-x64-musl@1.0.2': + optional: true + + '@rolldown/binding-openharmony-arm64@1.0.2': + optional: true + + '@rolldown/binding-wasm32-wasi@1.0.2': + dependencies: + '@emnapi/core': 1.10.0 + '@emnapi/runtime': 1.10.0 + '@napi-rs/wasm-runtime': 1.1.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) + optional: true + + '@rolldown/binding-win32-arm64-msvc@1.0.2': + optional: true + + '@rolldown/binding-win32-x64-msvc@1.0.2': + optional: true + + '@rolldown/pluginutils@1.0.1': {} + + '@rollup/rollup-android-arm-eabi@4.60.4': + optional: true + + '@rollup/rollup-android-arm64@4.60.4': + optional: true + + '@rollup/rollup-darwin-arm64@4.60.4': + optional: true + + '@rollup/rollup-darwin-x64@4.60.4': + optional: true + + '@rollup/rollup-freebsd-arm64@4.60.4': + optional: true + + '@rollup/rollup-freebsd-x64@4.60.4': + optional: true + + '@rollup/rollup-linux-arm-gnueabihf@4.60.4': + optional: true + + '@rollup/rollup-linux-arm-musleabihf@4.60.4': + optional: true + + '@rollup/rollup-linux-arm64-gnu@4.60.4': + optional: true + + '@rollup/rollup-linux-arm64-musl@4.60.4': + optional: true + + '@rollup/rollup-linux-loong64-gnu@4.60.4': + optional: true + + '@rollup/rollup-linux-loong64-musl@4.60.4': + optional: true + + '@rollup/rollup-linux-ppc64-gnu@4.60.4': + optional: true + + '@rollup/rollup-linux-ppc64-musl@4.60.4': + optional: true + + '@rollup/rollup-linux-riscv64-gnu@4.60.4': + optional: true + + '@rollup/rollup-linux-riscv64-musl@4.60.4': + optional: true + + '@rollup/rollup-linux-s390x-gnu@4.60.4': + optional: true + + '@rollup/rollup-linux-x64-gnu@4.60.4': + optional: true + + '@rollup/rollup-linux-x64-musl@4.60.4': + optional: true + + '@rollup/rollup-openbsd-x64@4.60.4': + optional: true + + '@rollup/rollup-openharmony-arm64@4.60.4': + optional: true + + '@rollup/rollup-win32-arm64-msvc@4.60.4': + optional: true + + '@rollup/rollup-win32-ia32-msvc@4.60.4': + optional: true + + '@rollup/rollup-win32-x64-gnu@4.60.4': + optional: true + + '@rollup/rollup-win32-x64-msvc@4.60.4': + optional: true + + '@standard-schema/spec@1.1.0': {} + + '@turbo/darwin-64@2.9.16': + optional: true + + '@turbo/darwin-arm64@2.9.16': + optional: true + + '@turbo/linux-64@2.9.16': + optional: true + + '@turbo/linux-arm64@2.9.16': + optional: true + + '@turbo/windows-64@2.9.16': + optional: true + + '@turbo/windows-arm64@2.9.16': + optional: true + + '@tybys/wasm-util@0.10.2': + dependencies: + tslib: 2.8.1 + optional: true + + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + + '@types/deep-eql@4.0.2': {} + + '@types/estree@1.0.8': {} + + '@types/estree@1.0.9': {} + + '@vitest/expect@4.1.7': + dependencies: + '@standard-schema/spec': 1.1.0 + '@types/chai': 5.2.3 + '@vitest/spy': 4.1.7 + '@vitest/utils': 4.1.7 + chai: 6.2.2 + tinyrainbow: 3.1.0 + + '@vitest/mocker@4.1.7(vite@8.0.14(esbuild@0.27.7))': + dependencies: + '@vitest/spy': 4.1.7 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 8.0.14(esbuild@0.27.7) + + '@vitest/pretty-format@4.1.7': + dependencies: + tinyrainbow: 3.1.0 + + '@vitest/runner@4.1.7': + dependencies: + '@vitest/utils': 4.1.7 + pathe: 2.0.3 + + '@vitest/snapshot@4.1.7': + dependencies: + '@vitest/pretty-format': 4.1.7 + '@vitest/utils': 4.1.7 + magic-string: 0.30.21 + pathe: 2.0.3 + + '@vitest/spy@4.1.7': {} + + '@vitest/utils@4.1.7': + dependencies: + '@vitest/pretty-format': 4.1.7 + convert-source-map: 2.0.0 + tinyrainbow: 3.1.0 + + acorn@8.16.0: {} + + any-promise@1.3.0: {} + + assertion-error@2.0.1: {} + + bundle-require@5.1.0(esbuild@0.27.7): + dependencies: + esbuild: 0.27.7 + load-tsconfig: 0.2.5 + + cac@6.7.14: {} + + chai@6.2.2: {} + + chokidar@4.0.3: + dependencies: + readdirp: 4.1.2 + + commander@4.1.1: {} + + confbox@0.1.8: {} + + consola@3.4.2: {} + + convert-source-map@2.0.0: {} + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + detect-libc@2.1.2: {} + + es-module-lexer@2.1.0: {} + + esbuild@0.27.7: + optionalDependencies: + '@esbuild/aix-ppc64': 0.27.7 + '@esbuild/android-arm': 0.27.7 + '@esbuild/android-arm64': 0.27.7 + '@esbuild/android-x64': 0.27.7 + '@esbuild/darwin-arm64': 0.27.7 + '@esbuild/darwin-x64': 0.27.7 + '@esbuild/freebsd-arm64': 0.27.7 + '@esbuild/freebsd-x64': 0.27.7 + '@esbuild/linux-arm': 0.27.7 + '@esbuild/linux-arm64': 0.27.7 + '@esbuild/linux-ia32': 0.27.7 + '@esbuild/linux-loong64': 0.27.7 + '@esbuild/linux-mips64el': 0.27.7 + '@esbuild/linux-ppc64': 0.27.7 + '@esbuild/linux-riscv64': 0.27.7 + '@esbuild/linux-s390x': 0.27.7 + '@esbuild/linux-x64': 0.27.7 + '@esbuild/netbsd-arm64': 0.27.7 + '@esbuild/netbsd-x64': 0.27.7 + '@esbuild/openbsd-arm64': 0.27.7 + '@esbuild/openbsd-x64': 0.27.7 + '@esbuild/openharmony-arm64': 0.27.7 + '@esbuild/sunos-x64': 0.27.7 + '@esbuild/win32-arm64': 0.27.7 + '@esbuild/win32-ia32': 0.27.7 + '@esbuild/win32-x64': 0.27.7 + + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.9 + + expect-type@1.3.0: {} + + fdir@6.5.0(picomatch@4.0.4): + optionalDependencies: + picomatch: 4.0.4 + + fix-dts-default-cjs-exports@1.0.1: + dependencies: + magic-string: 0.30.21 + mlly: 1.8.2 + rollup: 4.60.4 + + fsevents@2.3.3: + optional: true + + joycon@3.1.1: {} + + lightningcss-android-arm64@1.32.0: + optional: true + + lightningcss-darwin-arm64@1.32.0: + optional: true + + lightningcss-darwin-x64@1.32.0: + optional: true + + lightningcss-freebsd-x64@1.32.0: + optional: true + + lightningcss-linux-arm-gnueabihf@1.32.0: + optional: true + + lightningcss-linux-arm64-gnu@1.32.0: + optional: true + + lightningcss-linux-arm64-musl@1.32.0: + optional: true + + lightningcss-linux-x64-gnu@1.32.0: + optional: true + + lightningcss-linux-x64-musl@1.32.0: + optional: true + + lightningcss-win32-arm64-msvc@1.32.0: + optional: true + + lightningcss-win32-x64-msvc@1.32.0: + optional: true + + lightningcss@1.32.0: + dependencies: + detect-libc: 2.1.2 + optionalDependencies: + lightningcss-android-arm64: 1.32.0 + lightningcss-darwin-arm64: 1.32.0 + lightningcss-darwin-x64: 1.32.0 + lightningcss-freebsd-x64: 1.32.0 + lightningcss-linux-arm-gnueabihf: 1.32.0 + lightningcss-linux-arm64-gnu: 1.32.0 + lightningcss-linux-arm64-musl: 1.32.0 + lightningcss-linux-x64-gnu: 1.32.0 + lightningcss-linux-x64-musl: 1.32.0 + lightningcss-win32-arm64-msvc: 1.32.0 + lightningcss-win32-x64-msvc: 1.32.0 + + lilconfig@3.1.3: {} + + lines-and-columns@1.2.4: {} + + load-tsconfig@0.2.5: {} + + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + + mlly@1.8.2: + dependencies: + acorn: 8.16.0 + pathe: 2.0.3 + pkg-types: 1.3.1 + ufo: 1.6.4 + + ms@2.1.3: {} + + mz@2.7.0: + dependencies: + any-promise: 1.3.0 + object-assign: 4.1.1 + thenify-all: 1.6.0 + + nanoid@3.3.12: {} + + object-assign@4.1.1: {} + + obug@2.1.1: {} + + pathe@2.0.3: {} + + picocolors@1.1.1: {} + + picomatch@4.0.4: {} + + pirates@4.0.7: {} + + pkg-types@1.3.1: + dependencies: + confbox: 0.1.8 + mlly: 1.8.2 + pathe: 2.0.3 + + postcss-load-config@6.0.1(postcss@8.5.15): + dependencies: + lilconfig: 3.1.3 + optionalDependencies: + postcss: 8.5.15 + + postcss@8.5.15: + dependencies: + nanoid: 3.3.12 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + readdirp@4.1.2: {} + + resolve-from@5.0.0: {} + + rolldown@1.0.2: + dependencies: + '@oxc-project/types': 0.132.0 + '@rolldown/pluginutils': 1.0.1 + optionalDependencies: + '@rolldown/binding-android-arm64': 1.0.2 + '@rolldown/binding-darwin-arm64': 1.0.2 + '@rolldown/binding-darwin-x64': 1.0.2 + '@rolldown/binding-freebsd-x64': 1.0.2 + '@rolldown/binding-linux-arm-gnueabihf': 1.0.2 + '@rolldown/binding-linux-arm64-gnu': 1.0.2 + '@rolldown/binding-linux-arm64-musl': 1.0.2 + '@rolldown/binding-linux-ppc64-gnu': 1.0.2 + '@rolldown/binding-linux-s390x-gnu': 1.0.2 + '@rolldown/binding-linux-x64-gnu': 1.0.2 + '@rolldown/binding-linux-x64-musl': 1.0.2 + '@rolldown/binding-openharmony-arm64': 1.0.2 + '@rolldown/binding-wasm32-wasi': 1.0.2 + '@rolldown/binding-win32-arm64-msvc': 1.0.2 + '@rolldown/binding-win32-x64-msvc': 1.0.2 + + rollup@4.60.4: + dependencies: + '@types/estree': 1.0.8 + optionalDependencies: + '@rollup/rollup-android-arm-eabi': 4.60.4 + '@rollup/rollup-android-arm64': 4.60.4 + '@rollup/rollup-darwin-arm64': 4.60.4 + '@rollup/rollup-darwin-x64': 4.60.4 + '@rollup/rollup-freebsd-arm64': 4.60.4 + '@rollup/rollup-freebsd-x64': 4.60.4 + '@rollup/rollup-linux-arm-gnueabihf': 4.60.4 + '@rollup/rollup-linux-arm-musleabihf': 4.60.4 + '@rollup/rollup-linux-arm64-gnu': 4.60.4 + '@rollup/rollup-linux-arm64-musl': 4.60.4 + '@rollup/rollup-linux-loong64-gnu': 4.60.4 + '@rollup/rollup-linux-loong64-musl': 4.60.4 + '@rollup/rollup-linux-ppc64-gnu': 4.60.4 + '@rollup/rollup-linux-ppc64-musl': 4.60.4 + '@rollup/rollup-linux-riscv64-gnu': 4.60.4 + '@rollup/rollup-linux-riscv64-musl': 4.60.4 + '@rollup/rollup-linux-s390x-gnu': 4.60.4 + '@rollup/rollup-linux-x64-gnu': 4.60.4 + '@rollup/rollup-linux-x64-musl': 4.60.4 + '@rollup/rollup-openbsd-x64': 4.60.4 + '@rollup/rollup-openharmony-arm64': 4.60.4 + '@rollup/rollup-win32-arm64-msvc': 4.60.4 + '@rollup/rollup-win32-ia32-msvc': 4.60.4 + '@rollup/rollup-win32-x64-gnu': 4.60.4 + '@rollup/rollup-win32-x64-msvc': 4.60.4 + fsevents: 2.3.3 + + siginfo@2.0.0: {} + + source-map-js@1.2.1: {} + + source-map@0.7.6: {} + + stackback@0.0.2: {} + + std-env@4.1.0: {} + + sucrase@3.35.1: + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + commander: 4.1.1 + lines-and-columns: 1.2.4 + mz: 2.7.0 + pirates: 4.0.7 + tinyglobby: 0.2.16 + ts-interface-checker: 0.1.13 + + thenify-all@1.6.0: + dependencies: + thenify: 3.3.1 + + thenify@3.3.1: + dependencies: + any-promise: 1.3.0 + + tinybench@2.9.0: {} + + tinyexec@0.3.2: {} + + tinyexec@1.2.3: {} + + tinyglobby@0.2.16: + dependencies: + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 + + tinyrainbow@3.1.0: {} + + tree-kill@1.2.2: {} + + ts-interface-checker@0.1.13: {} + + tslib@2.8.1: + optional: true + + tsup@8.5.1(postcss@8.5.15)(typescript@5.9.3): + dependencies: + bundle-require: 5.1.0(esbuild@0.27.7) + cac: 6.7.14 + chokidar: 4.0.3 + consola: 3.4.2 + debug: 4.4.3 + esbuild: 0.27.7 + fix-dts-default-cjs-exports: 1.0.1 + joycon: 3.1.1 + picocolors: 1.1.1 + postcss-load-config: 6.0.1(postcss@8.5.15) + resolve-from: 5.0.0 + rollup: 4.60.4 + source-map: 0.7.6 + sucrase: 3.35.1 + tinyexec: 0.3.2 + tinyglobby: 0.2.16 + tree-kill: 1.2.2 + optionalDependencies: + postcss: 8.5.15 + typescript: 5.9.3 + transitivePeerDependencies: + - jiti + - supports-color + - tsx + - yaml + + turbo@2.9.16: + optionalDependencies: + '@turbo/darwin-64': 2.9.16 + '@turbo/darwin-arm64': 2.9.16 + '@turbo/linux-64': 2.9.16 + '@turbo/linux-arm64': 2.9.16 + '@turbo/windows-64': 2.9.16 + '@turbo/windows-arm64': 2.9.16 + + typescript@5.9.3: {} + + ufo@1.6.4: {} + + vite@8.0.14(esbuild@0.27.7): + dependencies: + lightningcss: 1.32.0 + picomatch: 4.0.4 + postcss: 8.5.15 + rolldown: 1.0.2 + tinyglobby: 0.2.16 + optionalDependencies: + esbuild: 0.27.7 + fsevents: 2.3.3 + + vitest@4.1.7(vite@8.0.14(esbuild@0.27.7)): + dependencies: + '@vitest/expect': 4.1.7 + '@vitest/mocker': 4.1.7(vite@8.0.14(esbuild@0.27.7)) + '@vitest/pretty-format': 4.1.7 + '@vitest/runner': 4.1.7 + '@vitest/snapshot': 4.1.7 + '@vitest/spy': 4.1.7 + '@vitest/utils': 4.1.7 + es-module-lexer: 2.1.0 + expect-type: 1.3.0 + magic-string: 0.30.21 + obug: 2.1.1 + pathe: 2.0.3 + picomatch: 4.0.4 + std-env: 4.1.0 + tinybench: 2.9.0 + tinyexec: 1.2.3 + tinyglobby: 0.2.16 + tinyrainbow: 3.1.0 + vite: 8.0.14(esbuild@0.27.7) + why-is-node-running: 2.3.0 + transitivePeerDependencies: + - msw + + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 diff --git a/src/ts/pnpm-workspace.yaml b/src/ts/pnpm-workspace.yaml new file mode 100644 index 0000000..67afeb7 --- /dev/null +++ b/src/ts/pnpm-workspace.yaml @@ -0,0 +1,10 @@ +packages: + - secrets + # - flags # @cocoar/flags — feature flags & entitlements (planned) + + # esbuild ships a postinstall that fetches its platform binary (used by tsup + vitest). + # pnpm 10+ blocks build scripts by default; allowlist it so installs are non-interactive. +allowBuilds: + esbuild: true +onlyBuiltDependencies: + - esbuild diff --git a/src/ts/secrets/README.md b/src/ts/secrets/README.md new file mode 100644 index 0000000..096c0b7 --- /dev/null +++ b/src/ts/secrets/README.md @@ -0,0 +1,55 @@ +# @cocoar/secrets + +Encrypt secrets for **Cocoar.Configuration** client-side — in the browser or Node — so the server +stores only an encrypted envelope and **never sees the plaintext**. + +The server publishes the public half of its secrets encryption key; this library uses it to seal a +value with hybrid encryption (a one-time **AES-256-GCM** data key wrapped with the server's +**RSA-OAEP-SHA256** public key). The resulting `cocoar.secret` envelope is what you store (e.g. via a +WritableStore secret-write endpoint). Only the server, holding the private key, can decrypt it. + +## Install + +```bash +npm install @cocoar/secrets +``` + +Requires WebCrypto (`globalThis.crypto.subtle`) — every modern browser and Node 18+. + +## Usage + +```ts +import { fetchEncryptionKey, encryptSecret } from "@cocoar/secrets"; + +// 1. Fetch the server's published public key. +const key = await fetchEncryptionKey("/.well-known/cocoar/encryption-key"); + +// 2. Encrypt a secret value (string or any JSON-serializable object). +const envelope = await encryptSecret(key, "my-oauth-client-secret"); + +// 3. Send the envelope to your API — the plaintext never left the browser. +await fetch("/admin/config/oauth-secret", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(envelope), +}); +``` + +For multi-tenant servers the same endpoint returns the current tenant's key (resolved server-side), +so no client changes are needed. + +## API + +- `fetchEncryptionKey(url, init?)` → `Promise` — fetch the published public key. +- `encryptSecret(key, value)` → `Promise` — seal a value into a `cocoar.secret` envelope. +- `base64UrlEncode` / `base64UrlDecode` — base64url (no padding) helpers. + +## Wire format + +The value is JSON-serialized (a string becomes a quoted JSON string, matching the server's +`JsonSerializer`), sealed with AES-256-GCM, and the data key is wrapped with RSA-OAEP-SHA256. All +binary fields are **base64url without padding**. + +## License + +Apache-2.0 diff --git a/src/ts/secrets/demo/index.html b/src/ts/secrets/demo/index.html new file mode 100644 index 0000000..79799b3 --- /dev/null +++ b/src/ts/secrets/demo/index.html @@ -0,0 +1,131 @@ + + + + + + @cocoar/secrets — encrypt demo + + + +

@cocoar/secrets

+

+ Encrypt a secret in the browser with the server's public key. The plaintext never leaves this page in + clear form — only the encrypted cocoar.secret envelope does. +

+ +
+

1 · Server public key

+

+ Paste the JSON from GET /.well-known/cocoar/encryption-key, or generate a throwaway demo + key to try the full round-trip offline. +

+ + + +
+ +
+

2 · Encrypt a secret

+ + + + +
+ +

+
+ + + + diff --git a/src/ts/secrets/package.json b/src/ts/secrets/package.json new file mode 100644 index 0000000..3607f8a --- /dev/null +++ b/src/ts/secrets/package.json @@ -0,0 +1,51 @@ +{ + "name": "@cocoar/secrets", + "version": "0.1.0", + "description": "Encrypt secrets for Cocoar.Configuration in the browser or Node — the server stores only an encrypted envelope and never sees the plaintext.", + "type": "module", + "exports": { + ".": { + "import": "./dist/index.js", + "require": "./dist/index.cjs" + } + }, + "main": "./dist/index.cjs", + "module": "./dist/index.js", + "types": "./dist/index.d.ts", + "scripts": { + "build": "tsup", + "dev": "tsup --watch", + "test": "vitest run", + "test:watch": "vitest", + "gen:fixtures": "node scripts/gen-crosslang-fixtures.mjs" + }, + "files": [ + "dist/" + ], + "sideEffects": false, + "keywords": [ + "cocoar", + "configuration", + "secrets", + "encryption", + "rsa-oaep", + "aes-gcm", + "webcrypto" + ], + "author": "Bernhard Windisch", + "license": "Apache-2.0", + "repository": { + "type": "git", + "url": "git+https://github.com/cocoar-dev/cocoar.configuration.git", + "directory": "src/ts/secrets" + }, + "homepage": "https://docs.cocoar.dev/configuration/", + "devDependencies": { + "tsup": "^8.0.0", + "typescript": "^5.7.0", + "vitest": "^4.1.0" + }, + "publishConfig": { + "access": "public" + } +} diff --git a/src/ts/secrets/scripts/gen-crosslang-fixtures.mjs b/src/ts/secrets/scripts/gen-crosslang-fixtures.mjs new file mode 100644 index 0000000..74b918b --- /dev/null +++ b/src/ts/secrets/scripts/gen-crosslang-fixtures.mjs @@ -0,0 +1,61 @@ +// Regenerates the cross-language golden fixture: a TEST RSA keypair + an envelope produced by the BUILT +// @cocoar/secrets library, written into the .NET test project. A .NET xunit test then decrypts the envelope +// and asserts the plaintext — proving the TS wire format is byte-compatible with the .NET decryptor. +// +// Run from src/ts: pnpm --filter @cocoar/secrets gen:fixtures (build first; this imports dist/) +// +// The private key is a throwaway TEST key — it is NOT used anywhere in production. + +import { writeFileSync, mkdirSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { encryptSecret } from "../dist/index.js"; + +const subtle = globalThis.crypto.subtle; +const here = dirname(fileURLToPath(import.meta.url)); + +const b64 = (buf) => Buffer.from(buf).toString("base64"); +const b64url = (buf) => Buffer.from(buf).toString("base64url"); + +// Includes a non-ASCII character to exercise UTF-8 round-trip across the stacks. +const PLAINTEXT = "cross-language-secret-✓"; + +const pair = await subtle.generateKey( + { name: "RSA-OAEP", modulusLength: 2048, publicExponent: new Uint8Array([1, 0, 1]), hash: "SHA-256" }, + true, + ["encrypt", "decrypt"], +); + +const spki = new Uint8Array(await subtle.exportKey("spki", pair.publicKey)); +const pkcs8 = new Uint8Array(await subtle.exportKey("pkcs8", pair.privateKey)); + +const publishedKey = { + kid: "crosslang-test", + alg: "RSA-OAEP-AES256-GCM", + walg: "RSA-OAEP-256", + enc: "AES-256-GCM", + format: "spki", + encoding: "base64url", + publicKey: b64url(spki), +}; + +const envelope = await encryptSecret(publishedKey, PLAINTEXT); + +const fixture = { + _comment: + "TEST-ONLY cross-language fixture (TS encrypt -> .NET decrypt). privateKeyPkcs8 is a throwaway test key, " + + "never used in production. Regenerate: pnpm --filter @cocoar/secrets gen:fixtures (from src/ts).", + plaintext: PLAINTEXT, + privateKeyPkcs8: b64(pkcs8), + publishedKey, + envelope, +}; + +const out = join( + here, + "..", "..", "..", + "tests", "Cocoar.Configuration.Secrets.Tests", "CrossLang", "ts-envelope.fixture.json", +); +mkdirSync(dirname(out), { recursive: true }); +writeFileSync(out, JSON.stringify(fixture, null, 2) + "\n"); +console.log("Wrote cross-language fixture:", out); diff --git a/src/ts/secrets/src/index.ts b/src/ts/secrets/src/index.ts new file mode 100644 index 0000000..364c63d --- /dev/null +++ b/src/ts/secrets/src/index.ts @@ -0,0 +1,165 @@ +/** + * `@cocoar/secrets` — encrypt a secret for **Cocoar.Configuration** so the server stores only an + * encrypted envelope and never sees the plaintext. + * + * Hybrid encryption matching the server's decryption contract: a one-time **AES-256-GCM** data key + * seals the value, and that key is wrapped with the server's **RSA-OAEP-SHA256** public key. Runs + * anywhere WebCrypto is available — browsers and Node 18+ (`globalThis.crypto.subtle`). + * + * @example + * ```ts + * const key = await fetchEncryptionKey("/.well-known/cocoar/encryption-key"); + * const envelope = await encryptSecret(key, "my-oauth-client-secret"); + * await fetch("/admin/config/oauth-secret", { method: "POST", body: JSON.stringify(envelope) }); + * ``` + */ + +/** Overall hybrid scheme identifier (envelope `alg`). */ +export const ALG_HYBRID = "RSA-OAEP-AES256-GCM"; +/** Key-wrapping algorithm identifier (envelope `walg`). */ +export const ALG_WRAP = "RSA-OAEP-256"; +/** Data-encryption algorithm identifier (published key `enc`). */ +export const ALG_ENC = "AES-256-GCM"; + +/** The public-key document the server publishes at `/.well-known/cocoar/encryption-key`. */ +export interface PublishedKey { + /** Key id (single-tenant: the configured kid; multi-tenant: the tenant id). */ + kid: string; + /** Overall scheme — `"RSA-OAEP-AES256-GCM"`. */ + alg: string; + /** Key-wrap algorithm — `"RSA-OAEP-256"`. */ + walg: string; + /** Data-encryption algorithm — `"AES-256-GCM"`. */ + enc: string; + /** Public-key structure — `"spki"` (X.509 SubjectPublicKeyInfo, DER). */ + format: string; + /** Encoding of {@link publicKey} — `"base64url"` (no padding). */ + encoding: string; + /** The RSA public key: DER SubjectPublicKeyInfo, base64url-encoded without padding. */ + publicKey: string; +} + +/** The encrypted envelope the server accepts (`cocoar.secret`, version 1). */ +export interface SecretEnvelope { + type: "cocoar.secret"; + version: 1; + kid: string; + alg: string; + /** base64url: the AES key wrapped with RSA-OAEP-SHA256. */ + wk: string; + walg: string; + /** base64url: the 96-bit AES-GCM IV. */ + iv: string; + /** base64url: the ciphertext. */ + ct: string; + /** base64url: the 128-bit GCM authentication tag. */ + tag: string; +} + +function subtle(): SubtleCrypto { + const c = (globalThis as { crypto?: Crypto }).crypto; + if (!c?.subtle) { + throw new Error( + "WebCrypto is unavailable. Use a browser or Node 18+ where globalThis.crypto.subtle exists.", + ); + } + return c.subtle; +} + +/** Encode bytes as base64url **without** padding (the wire format Cocoar expects). */ +export function base64UrlEncode(bytes: Uint8Array): string { + let binary = ""; + for (let i = 0; i < bytes.length; i++) { + binary += String.fromCharCode(bytes[i]!); + } + return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); +} + +/** Decode base64url (with or without padding) into bytes. */ +export function base64UrlDecode(value: string): Uint8Array { + const normalized = value.replace(/-/g, "+").replace(/_/g, "/"); + const padded = normalized + "=".repeat((4 - (normalized.length % 4)) % 4); + const binary = atob(padded); + const out = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) { + out[i] = binary.charCodeAt(i); + } + return out; +} + +function assertSupported(key: PublishedKey): void { + if (key.format !== "spki") { + throw new Error(`Unsupported public-key format '${key.format}' (expected 'spki').`); + } + if (key.encoding !== "base64url") { + throw new Error(`Unsupported public-key encoding '${key.encoding}' (expected 'base64url').`); + } + if (key.walg !== ALG_WRAP) { + throw new Error(`Unsupported key-wrap algorithm '${key.walg}' (expected '${ALG_WRAP}').`); + } + if (key.enc !== ALG_ENC) { + throw new Error(`Unsupported data-encryption algorithm '${key.enc}' (expected '${ALG_ENC}').`); + } +} + +/** Fetch the server's published public key from the given URL. */ +export async function fetchEncryptionKey(url: string, init?: RequestInit): Promise { + const res = await fetch(url, init); + if (!res.ok) { + throw new Error(`Failed to fetch encryption key from ${url}: HTTP ${res.status}.`); + } + return (await res.json()) as PublishedKey; +} + +/** + * Encrypt a value into a Cocoar secret envelope using the server's published public key. + * + * The value is JSON-serialized first (a string becomes a quoted JSON string, matching the server's + * `JsonSerializer` round-trip), then sealed with a one-time AES-256-GCM key that is wrapped with the + * server's RSA-OAEP-SHA256 public key. The plaintext never leaves the caller in clear form. + */ +export async function encryptSecret(key: PublishedKey, value: unknown): Promise { + assertSupported(key); + const crypto = subtle(); + + const plaintext = new TextEncoder().encode(JSON.stringify(value)); + + // One-time AES-256-GCM data-encryption key + 96-bit IV. + const dek = globalThis.crypto.getRandomValues(new Uint8Array(32)); + const iv = globalThis.crypto.getRandomValues(new Uint8Array(12)); + const aesKey = await crypto.importKey("raw", dek, { name: "AES-GCM" }, false, ["encrypt"]); + const sealed = new Uint8Array( + await crypto.encrypt({ name: "AES-GCM", iv, tagLength: 128 }, aesKey, plaintext), + ); + + // WebCrypto returns ciphertext || tag concatenated; the server expects them as separate fields. + const ct = sealed.slice(0, sealed.length - 16); + const tag = sealed.slice(sealed.length - 16); + + // Wrap the AES key with the server's RSA-OAEP-SHA256 public key. (Cast to BufferSource: TS 5.7+ types a + // bare Uint8Array as Uint8Array, which WebCrypto's BufferSource doesn't accept — the public + // base64UrlDecode return type stays a plain Uint8Array so older-TS consumers aren't forced onto the generic.) + const rsaPublicKey = await crypto.importKey( + "spki", + base64UrlDecode(key.publicKey) as BufferSource, + { name: "RSA-OAEP", hash: "SHA-256" }, + false, + ["encrypt"], + ); + const wrappedKey = new Uint8Array(await crypto.encrypt({ name: "RSA-OAEP" }, rsaPublicKey, dek)); + + // Best-effort: drop the plaintext key bytes once wrapped. + dek.fill(0); + + return { + type: "cocoar.secret", + version: 1, + kid: key.kid, + alg: ALG_HYBRID, + wk: base64UrlEncode(wrappedKey), + walg: ALG_WRAP, + iv: base64UrlEncode(iv), + ct: base64UrlEncode(ct), + tag: base64UrlEncode(tag), + }; +} diff --git a/src/ts/secrets/tests/encrypt.test.ts b/src/ts/secrets/tests/encrypt.test.ts new file mode 100644 index 0000000..e18f930 --- /dev/null +++ b/src/ts/secrets/tests/encrypt.test.ts @@ -0,0 +1,95 @@ +import { describe, it, expect } from "vitest"; +import { encryptSecret, base64UrlDecode, type PublishedKey, type SecretEnvelope } from "../src/index"; + +const subtle = globalThis.crypto.subtle; + +function b64url(bytes: Uint8Array): string { + let binary = ""; + for (let i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i]!); + return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); +} + +/** A fresh RSA-OAEP keypair, shaped into the server's PublishedKey document. */ +async function makeKey(): Promise<{ key: PublishedKey; privateKey: CryptoKey }> { + const pair = (await subtle.generateKey( + { name: "RSA-OAEP", modulusLength: 2048, publicExponent: new Uint8Array([1, 0, 1]), hash: "SHA-256" }, + true, + ["encrypt", "decrypt"], + )) as CryptoKeyPair; + + const spki = new Uint8Array(await subtle.exportKey("spki", pair.publicKey)); + return { + key: { + kid: "test", + alg: "RSA-OAEP-AES256-GCM", + walg: "RSA-OAEP-256", + enc: "AES-256-GCM", + format: "spki", + encoding: "base64url", + publicKey: b64url(spki), + }, + privateKey: pair.privateKey, + }; +} + +/** Mirror of the server's decryption: RSA-OAEP-SHA256 unwrap + AES-256-GCM open. */ +async function decrypt(envelope: SecretEnvelope, privateKey: CryptoKey): Promise { + const dek = await subtle.decrypt({ name: "RSA-OAEP" }, privateKey, base64UrlDecode(envelope.wk)); + const aesKey = await subtle.importKey("raw", dek, { name: "AES-GCM" }, false, ["decrypt"]); + + const ct = base64UrlDecode(envelope.ct); + const tag = base64UrlDecode(envelope.tag); + const sealed = new Uint8Array(ct.length + tag.length); + sealed.set(ct, 0); + sealed.set(tag, ct.length); + + const plaintext = await subtle.decrypt( + { name: "AES-GCM", iv: base64UrlDecode(envelope.iv), tagLength: 128 }, + aesKey, + sealed, + ); + return new TextDecoder().decode(plaintext); +} + +describe("encryptSecret", () => { + it("produces a well-formed cocoar.secret envelope", async () => { + const { key } = await makeKey(); + const env = await encryptSecret(key, "super-secret-value"); + + expect(env.type).toBe("cocoar.secret"); + expect(env.version).toBe(1); + expect(env.kid).toBe("test"); + expect(env.alg).toBe("RSA-OAEP-AES256-GCM"); + expect(env.walg).toBe("RSA-OAEP-256"); + + // All binary fields are base64url without padding. + for (const field of [env.wk, env.iv, env.ct, env.tag]) { + expect(field).toMatch(/^[A-Za-z0-9_-]+$/); + } + }); + + it("round-trips a string (JSON-quoted plaintext, like the server)", async () => { + const { key, privateKey } = await makeKey(); + const env = await encryptSecret(key, "super-secret-value"); + expect(await decrypt(env, privateKey)).toBe(JSON.stringify("super-secret-value")); + }); + + it("round-trips an object value", async () => { + const { key, privateKey } = await makeKey(); + const value = { clientId: "abc", clientSecret: "xyz", scopes: ["a", "b"] }; + const env = await encryptSecret(key, value); + expect(JSON.parse(await decrypt(env, privateKey))).toEqual(value); + }); + + it("uses a fresh IV per call (no nonce reuse)", async () => { + const { key } = await makeKey(); + const a = await encryptSecret(key, "x"); + const b = await encryptSecret(key, "x"); + expect(a.iv).not.toBe(b.iv); + }); + + it("rejects an unsupported key-wrap algorithm", async () => { + const { key } = await makeKey(); + await expect(encryptSecret({ ...key, walg: "RSA-OAEP-1" }, "x")).rejects.toThrow(); + }); +}); diff --git a/src/ts/secrets/tsconfig.json b/src/ts/secrets/tsconfig.json new file mode 100644 index 0000000..54af6fa --- /dev/null +++ b/src/ts/secrets/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../tsconfig.base.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src" + }, + "include": ["src"] +} diff --git a/src/ts/secrets/tsup.config.ts b/src/ts/secrets/tsup.config.ts new file mode 100644 index 0000000..644948a --- /dev/null +++ b/src/ts/secrets/tsup.config.ts @@ -0,0 +1,9 @@ +import { defineConfig } from "tsup"; + +export default defineConfig({ + entry: ["src/index.ts"], + format: ["esm", "cjs"], + dts: true, + clean: true, + sourcemap: true, +}); diff --git a/src/ts/secrets/vitest.config.ts b/src/ts/secrets/vitest.config.ts new file mode 100644 index 0000000..ce13bfd --- /dev/null +++ b/src/ts/secrets/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + include: ["tests/**/*.test.ts"], + testTimeout: 30_000, + }, +}); diff --git a/src/ts/tsconfig.base.json b/src/ts/tsconfig.base.json new file mode 100644 index 0000000..460bd22 --- /dev/null +++ b/src/ts/tsconfig.base.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "strict": true, + "noUncheckedIndexedAccess": true, + "declaration": true, + "forceConsistentCasingInFileNames": true, + "skipLibCheck": true + } +} diff --git a/src/ts/turbo.json b/src/ts/turbo.json new file mode 100644 index 0000000..3df3601 --- /dev/null +++ b/src/ts/turbo.json @@ -0,0 +1,15 @@ +{ + "$schema": "https://turbo.build/schema.json", + "tasks": { + "build": { + "outputs": ["dist/**"] + }, + "test": { + "outputs": [] + }, + "dev": { + "cache": false, + "persistent": true + } + } +} From c94edcee1489af184bf642cd97554de83e44fc70 Mon Sep 17 00:00:00 2001 From: Bernhard Windisch Date: Sun, 31 May 2026 12:02:50 +0200 Subject: [PATCH 16/18] docs: document the @cocoar/secrets client-side encryption library New "Browser & Client Encryption" guide page (install, usage, multi-tenant, wire format, API) covering @cocoar/secrets as the client counterpart to the key-publishing endpoint. Added to the Secrets sidebar and cross-linked from the "How a producer uses it" section of Publishing Encryption Keys. Co-Authored-By: Claude Sonnet 4.6 --- website/.vitepress/config.ts | 1 + website/guide/secrets/client-encryption.md | 61 ++++++++++++++++++++++ website/guide/secrets/key-publishing.md | 2 + 3 files changed, 64 insertions(+) create mode 100644 website/guide/secrets/client-encryption.md diff --git a/website/.vitepress/config.ts b/website/.vitepress/config.ts index 6f50bb5..b6ba5aa 100644 --- a/website/.vitepress/config.ts +++ b/website/.vitepress/config.ts @@ -113,6 +113,7 @@ export default defineConfig({ { text: 'Secret & Leases', link: '/guide/secrets/secret-type' }, { text: 'Encryption Setup', link: '/guide/secrets/encryption-setup' }, { text: 'Publishing Encryption Keys ', link: '/guide/secrets/key-publishing' }, + { text: 'Browser & Client Encryption ', link: '/guide/secrets/client-encryption' }, { text: 'CLI Tools', link: '/guide/secrets/cli' }, { text: 'Certificate Caching ', link: '/guide/secrets/certificate-caching' }, { text: 'Security Model ', link: '/guide/secrets/security-model' }, diff --git a/website/guide/secrets/client-encryption.md b/website/guide/secrets/client-encryption.md new file mode 100644 index 0000000..7c6e03a --- /dev/null +++ b/website/guide/secrets/client-encryption.md @@ -0,0 +1,61 @@ +# Browser & Client Encryption + +[Publishing Encryption Keys](/guide/secrets/key-publishing) exposes your server's **public** key so an external producer can build a `cocoar.secret` envelope. **`@cocoar/secrets`** is that producer for the browser and Node — a tiny, zero-dependency TypeScript library that encrypts a value with the published key so the **plaintext never reaches your server**; only the encrypted envelope does. + +It pairs with the publishing endpoint: the server holds the private key and decrypts on `Secret.Open()`; the client only ever sees the public key. + +## Install + +```bash +npm install @cocoar/secrets +``` + +Requires WebCrypto (`globalThis.crypto.subtle`) — every modern browser and Node 18+. No runtime dependencies. + +## Usage + +```ts +import { fetchEncryptionKey, encryptSecret } from "@cocoar/secrets"; + +// 1. Fetch the server's published public key. +const key = await fetchEncryptionKey("/.well-known/cocoar/encryption-key"); + +// 2. Encrypt a secret value (a string, or any JSON-serializable object). +const envelope = await encryptSecret(key, "my-oauth-client-secret"); + +// 3. POST the envelope to your API — the plaintext never left the browser. +await fetch("/admin/config/oauth-secret", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(envelope), +}); +``` + +The server stores the envelope as-is — for example through a [WritableStore](/guide/providers/writable-store) overlay via `SetSecretEnvelopeAsync` / `SetSecretAsync` — and decrypts it only when the typed `Secret` is opened. + +## Multi-tenant + +No client change is needed. The [per-tenant endpoint](/guide/secrets/key-publishing#multi-tenant) returns the current tenant's key, resolved server-side from `ITenantContext`. The browser fetches the same URL and gets the right key; the resulting envelope is stored against that tenant — e.g. `GetWritableStoreForTenant(tenantId).SetSecretAsync(...)`. + +## What it does + +`encryptSecret` performs hybrid encryption matching the server's decryption contract: + +- generates a one-time **AES-256-GCM** data key and a 96-bit IV, +- seals the JSON-serialized value with it (a string round-trips as a quoted JSON string), +- wraps the data key with the server's **RSA-OAEP-SHA256** public key, +- assembles a `cocoar.secret` envelope (all binary fields base64url, no padding). + +The wire format is the same one under [Custom Providers → Secrets](/guide/providers/custom#secrets-in-custom-providers); a TS→.NET round-trip is covered by a cross-language test so the two stacks stay byte-compatible. + +## API + +| Export | Purpose | +|---|---| +| `fetchEncryptionKey(url, init?)` | fetch the published key document | +| `encryptSecret(key, value)` | seal a value into a `cocoar.secret` envelope | +| `base64UrlEncode` / `base64UrlDecode` | base64url (no padding) helpers | + +## Versioning + +`@cocoar/secrets` is published to npm and versioned **independently** of the .NET (NuGet) packages — it codes against the stable published-key contract, not a specific library release. diff --git a/website/guide/secrets/key-publishing.md b/website/guide/secrets/key-publishing.md index 36fd9a8..22fd9c1 100644 --- a/website/guide/secrets/key-publishing.md +++ b/website/guide/secrets/key-publishing.md @@ -68,6 +68,8 @@ Every field name is pinned, so a host JSON naming policy can't rename it. There 2. Generate a random AES-256 DEK, encrypt the value with AES-GCM, wrap the DEK with RSA-OAEP-256, and assemble the `cocoar.secret` envelope (with `kid` stamped from the key). 3. Send the envelope to your server. It is stored as-is and decrypted only on `Secret.Open()`. +In the browser or Node, the **[`@cocoar/secrets`](/guide/secrets/client-encryption)** client does all three steps for you. + The envelope wire format is documented in [Custom Providers → Secrets](/guide/providers/custom#secrets-in-custom-providers). The same envelope can be written through a WritableStore overlay via `SetSecretEnvelopeAsync` / `SetSecretAsync` — including per tenant with `GetWritableStoreForTenant(tenantId).SetSecretAsync(...)`, which is how a tenant stores a secret encrypted to its own published key. ## Availability From e093ba50a8f5dc79de7f5238a1288282512c0a9d Mon Sep 17 00:00:00 2001 From: Bernhard Windisch Date: Sun, 31 May 2026 12:11:47 +0200 Subject: [PATCH 17/18] ci: build/test the TS client on PRs + manual npm publish workflow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 01-pr-validation: new "typescript" job (pnpm install/build/test of the src/ts workspace) so @cocoar/secrets is validated on every PR touching src/** and the cross-language fixture can't silently rot. - 06-publish-npm: manual (workflow_dispatch) publish of @cocoar/secrets to npm — builds, tests, then publishes the version in package.json with a dry-run option. The TS client versions and ships independently of the .NET (NuGet) release tags (NPM_TOKEN secret required). Co-Authored-By: Claude Sonnet 4.6 --- .github/workflows/01-pr-validation.yml | 35 ++++++++++++++ .github/workflows/06-publish-npm.yml | 66 ++++++++++++++++++++++++++ 2 files changed, 101 insertions(+) create mode 100644 .github/workflows/06-publish-npm.yml diff --git a/.github/workflows/01-pr-validation.yml b/.github/workflows/01-pr-validation.yml index 6b52e8e..3775458 100644 --- a/.github/workflows/01-pr-validation.yml +++ b/.github/workflows/01-pr-validation.yml @@ -58,3 +58,38 @@ jobs: run: dotnet pack -c Release --no-restore -p:Version=${{ steps.gv.outputs.SemVer }} working-directory: ./src + typescript: + name: TypeScript client (@cocoar/secrets) + runs-on: ubuntu-latest + timeout-minutes: 15 + permissions: + contents: read + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + with: + version: 11 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 22 + cache: pnpm + cache-dependency-path: src/ts/pnpm-lock.yaml + + - name: Install dependencies + run: pnpm install --frozen-lockfile + working-directory: ./src/ts + + - name: Build + run: pnpm build + working-directory: ./src/ts + + - name: Test + run: pnpm test + working-directory: ./src/ts + diff --git a/.github/workflows/06-publish-npm.yml b/.github/workflows/06-publish-npm.yml new file mode 100644 index 0000000..486d9db --- /dev/null +++ b/.github/workflows/06-publish-npm.yml @@ -0,0 +1,66 @@ +name: npm - Publish @cocoar/secrets + +# Manual, on-demand publish for the TypeScript client. @cocoar/secrets versions and ships INDEPENDENTLY +# of the .NET (NuGet) packages — it is NOT tied to a vX.Y.Z .NET release tag. The published version is +# whatever is in src/ts/secrets/package.json; bump it there, merge, then run this workflow. + +on: + workflow_dispatch: + inputs: + dry-run: + description: "Dry run — build, test and pack, but do NOT publish to npm" + type: boolean + default: false + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: false + +jobs: + publish: + name: Build, test & publish + runs-on: ubuntu-latest + timeout-minutes: 20 + permissions: + contents: read + defaults: + run: + working-directory: ./src/ts + + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + ref: ${{ github.ref }} # branch selected in the UI + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + with: + version: 11 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 22 + registry-url: 'https://registry.npmjs.org' + cache: pnpm + cache-dependency-path: src/ts/pnpm-lock.yaml + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Build + run: pnpm build + + - name: Test + run: pnpm test + + - name: Resolve version + run: | + VERSION=$(node -p "require('./secrets/package.json').version") + echo "Publishing @cocoar/secrets@$VERSION (dry-run=${{ inputs.dry-run }})" + + - name: Publish to npm + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + run: pnpm --filter @cocoar/secrets publish --no-git-checks ${{ inputs.dry-run && '--dry-run' || '' }} From b4dfa220d4fef18bc907e7fc81b8b717ac434883 Mon Sep 17 00:00:00 2001 From: Bernhard Windisch Date: Sun, 31 May 2026 16:08:34 +0200 Subject: [PATCH 18/18] docs: fix two stale doc traces (ADR-005 net line, deprecated accessor API) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ADR-005 "Engine impact → Net:" no longer claims a "new fan-out coordinator" subsystem: that contradicted the chosen v1 model (full-list-per-tenant gives automatic fan-out; the coordinator is deferred with seed-from-global, per §6, the table, and Settled decisions). Reworded to match. - v2-to-v3 migration guide now uses GetConfig() instead of the deprecated GetRequiredConfig() in its examples (identical behavior; required-optional.md marks GetRequiredConfig deprecated). Co-Authored-By: Claude Sonnet 4.6 --- website/adr/ADR-005-multi-tenant-configuration.md | 2 +- website/guide/migration/v2-to-v3.md | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/website/adr/ADR-005-multi-tenant-configuration.md b/website/adr/ADR-005-multi-tenant-configuration.md index 5e717d3..a2f3a6a 100644 --- a/website/adr/ADR-005-multi-tenant-configuration.md +++ b/website/adr/ADR-005-multi-tenant-configuration.md @@ -146,7 +146,7 @@ Tenant methods live on a **new** `ITenantConfigurationAccessor` that `ConfigMana | Abstractions | New `ITenantConfigurationAccessor`; existing `IConfigurationAccessor` unchanged | Additive | | Flags/Entitlements | `GetFeatureFlagsForTenant`/`GetEntitlementsForTenant` factory/cache (no generator change); tenant dimension on evaluator + REST endpoints | Additive | -**Net:** one structural change (ConfigManager ownership) + one new subsystem (fan-out coordinator). Everything else is additive reuse of existing per-instance machinery. No rewrite of the recompute/snapshot/reactive cores. +**Net:** one structural change (`ConfigManager` ownership → per-tenant `TenantPipeline` bundles). Everything else is additive reuse of existing per-instance machinery — fan-out is automatic via each tenant's own subscriptions, with **no coordinator subsystem in v1** (§6; the coordinator is deferred with seed-from-global). No rewrite of the recompute/snapshot/reactive cores. --- diff --git a/website/guide/migration/v2-to-v3.md b/website/guide/migration/v2-to-v3.md index ce17510..9fd1485 100644 --- a/website/guide/migration/v2-to-v3.md +++ b/website/guide/migration/v2-to-v3.md @@ -90,13 +90,13 @@ rule.For().FromFile("config.json") ```csharp // v2.0 rule.File(accessor => { - var tenant = accessor.GetRequiredConfig(); + var tenant = accessor.GetConfig(); return FileSourceRuleOptions.FromFilePath($"tenant-{tenant.Id}.json"); }).For() // v3.0 rule.For().FromFile(accessor => { - var tenant = accessor.GetRequiredConfig(); + var tenant = accessor.GetConfig(); return FileSourceRuleOptions.FromFilePath($"tenant-{tenant.Id}.json"); }) ``` @@ -114,7 +114,7 @@ rule.File("premium-features.json") // v3.0 — When(Func) rule.For().FromFile("premium-features.json") .When(accessor => { - var tenant = accessor.GetRequiredConfig(); + var tenant = accessor.GetConfig(); return tenant.Tier == "Premium"; }) ``` @@ -215,7 +215,7 @@ builder.AddCocoarConfiguration(rule => [ rule.For().FromFile("premium.json") .When(accessor => { - var tenant = accessor.GetRequiredConfig(); + var tenant = accessor.GetConfig(); return tenant.Tier == "Premium"; }),