diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..40604d7
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,45 @@
+name: CI
+
+on:
+ push:
+ branches: [ main ]
+ pull_request:
+ branches: [ main ]
+
+permissions:
+ contents: read
+
+jobs:
+ build-and-test:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/setup-dotnet@v4
+ with:
+ dotnet-version: 10.0.x
+ - name: Restore
+ working-directory: MagicSettings
+ run: dotnet restore MagicSettings.sln
+ - name: Build
+ id: build
+ working-directory: MagicSettings
+ shell: bash
+ run: |
+ set +e
+ dotnet build MagicSettings.sln --configuration Release --no-restore --consoleLoggerParameters:ErrorsOnly > ../build.log 2>&1
+ exit_code=$?
+ grep -E "(^|[[:space:]])(error|warning) (CS|NU|NETSDK|MSB)[0-9]+|Build FAILED|Build succeeded" ../build.log > ../build-summary.log || true
+ cat ../build-summary.log
+ exit $exit_code
+ - name: Upload build diagnostics
+ if: always()
+ uses: actions/upload-artifact@v4
+ with:
+ name: build-diagnostics
+ path: |
+ build.log
+ build-summary.log
+ if-no-files-found: error
+ - name: Test
+ working-directory: MagicSettings
+ run: dotnet test MagicSettings.sln --configuration Release --no-build --logger "console;verbosity=normal"
diff --git a/.gitignore b/.gitignore
index e2f136b..6840df3 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,22 +1,17 @@
-## Build output
[Bb]in/
[Oo]bj/
artifacts/
-*.user
-*.suo
+TestResults/
+coverage/
.idea/
.vs/
-
-## Runtime state and secrets
-**/.magic-ai/
-**/state/
+*.user
+*.suo
*.pfx
*.pem
*.key
*.password
-appsettings.Local.json
.env
-
-## Test output
-TestResults/
-coverage/
+**/.magicsettings.identity.json
+**/.magicsettings.identity.json.*
+appsettings.Local.json
diff --git a/MagicSettings/Directory.Build.props b/MagicSettings/Directory.Build.props
new file mode 100644
index 0000000..4bd9c1a
--- /dev/null
+++ b/MagicSettings/Directory.Build.props
@@ -0,0 +1,12 @@
+
+
+ net10.0
+ latest
+ enable
+ enable
+ true
+ true
+ $(NoWarn);1591
+ true
+
+
diff --git a/MagicSettings/MagicSettings.Server/MagicSettings.Server.csproj b/MagicSettings/MagicSettings.Server/MagicSettings.Server.csproj
index 237d661..a46af5e 100644
--- a/MagicSettings/MagicSettings.Server/MagicSettings.Server.csproj
+++ b/MagicSettings/MagicSettings.Server/MagicSettings.Server.csproj
@@ -1,9 +1,10 @@
-
-
-
- net10.0
- enable
- enable
-
-
+
+
+ MagicSettings.Server
+ Storage-agnostic server helpers for MagicSettings enrollment, synchronization, authorization, and proof verification.
+
+
+
+
+
diff --git a/MagicSettings/MagicSettings.Server/Source.01.cs b/MagicSettings/MagicSettings.Server/Source.01.cs
new file mode 100644
index 0000000..e896af6
--- /dev/null
+++ b/MagicSettings/MagicSettings.Server/Source.01.cs
@@ -0,0 +1,436 @@
+// Consolidated source file. See repository history and wiki for subsystem boundaries.
+using System.Security.Cryptography;
+using MagicSettings.Share;
+using Microsoft.AspNetCore.Http;
+using System.Collections.Concurrent;
+using System.Text;
+
+namespace MagicSettings.Server
+{
+public static class MagicAspNetProofVerificationExtensions
+{
+ public static async ValueTask VerifyHttpRequestAsync(
+ this MagicNodeProofVerifier verifier,
+ HttpRequest request,
+ string expectedAudience,
+ CancellationToken cancellationToken = default)
+ {
+ ArgumentNullException.ThrowIfNull(verifier);
+ ArgumentNullException.ThrowIfNull(request);
+ ArgumentException.ThrowIfNullOrWhiteSpace(expectedAudience);
+
+ if (!request.Headers.TryGetValue("Authorization", out var authorization))
+ {
+ return MagicProofVerificationResult.Invalid("The MagicNode authorization header is missing.");
+ }
+
+ var header = authorization.ToString();
+ const string prefix = "MagicNode ";
+ if (!header.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))
+ {
+ return MagicProofVerificationResult.Invalid("The authorization scheme is not MagicNode.");
+ }
+
+ MagicAuthenticationProof proof;
+ try
+ {
+ proof = MagicNodeProofCodec.Decode(header[prefix.Length..].Trim());
+ }
+ catch (Exception exception) when (exception is FormatException or System.Text.Json.JsonException)
+ {
+ return MagicProofVerificationResult.Invalid("The MagicNode authorization proof is malformed.");
+ }
+
+ request.EnableBuffering();
+ string bodyHash;
+ if (request.ContentLength is null or 0)
+ {
+ bodyHash = Convert.ToHexString(SHA256.HashData(Array.Empty())).ToLowerInvariant();
+ }
+ else
+ {
+ await using var buffer = new MemoryStream();
+ await request.Body.CopyToAsync(buffer, cancellationToken);
+ request.Body.Position = 0;
+ bodyHash = Convert.ToHexString(SHA256.HashData(buffer.ToArray())).ToLowerInvariant();
+ }
+
+ var uri = new Uri($"{request.Scheme}://{request.Host}{request.PathBase}{request.Path}{request.QueryString}");
+ return await verifier.VerifyAsync(
+ new(proof, expectedAudience, request.Method, uri, bodyHash, DateTimeOffset.UtcNow),
+ cancellationToken);
+ }
+}
+}
+namespace MagicSettings.Server
+{
+///
+/// Storage-agnostic credential lifecycle helpers for control-plane implementations.
+///
+public sealed class MagicCredentialAdministrationService
+{
+ private readonly IMagicCredentialRegistry _credentials;
+
+ public MagicCredentialAdministrationService(IMagicCredentialRegistry credentials) => _credentials = credentials;
+
+ public async ValueTask SetStatusAsync(
+ Guid nodeId,
+ Guid credentialId,
+ MagicCredentialStatus status,
+ CancellationToken cancellationToken = default)
+ {
+ var credential = await _credentials.FindAsync(nodeId, credentialId, cancellationToken);
+ if (credential is null)
+ {
+ return false;
+ }
+
+ await _credentials.UpsertAsync(credential with
+ {
+ Status = status,
+ UpdatedUtc = DateTimeOffset.UtcNow
+ }, cancellationToken);
+ return true;
+ }
+
+ public ValueTask ApproveAsync(Guid nodeId, Guid credentialId, CancellationToken cancellationToken = default)
+ => SetStatusAsync(nodeId, credentialId, MagicCredentialStatus.Approved, cancellationToken);
+
+ public ValueTask RevokeAsync(Guid nodeId, Guid credentialId, CancellationToken cancellationToken = default)
+ => SetStatusAsync(nodeId, credentialId, MagicCredentialStatus.Revoked, cancellationToken);
+}
+}
+namespace MagicSettings.Server
+{
+public sealed class MagicCredentialRotationService
+{
+ private readonly IMagicCredentialRegistry _credentials;
+
+ public MagicCredentialRotationService(IMagicCredentialRegistry credentials) => _credentials = credentials;
+
+ public async ValueTask ApplyAsync(
+ MagicIdentityContinuityProof continuity,
+ bool autoApproveNewCredential,
+ CancellationToken cancellationToken = default)
+ {
+ if (!MagicNodeProofVerifier.VerifyContinuity(continuity))
+ {
+ return MagicProofVerificationResult.Invalid("The identity continuity proof is invalid.");
+ }
+
+ var previous = await _credentials.FindAsync(
+ continuity.PreviousIdentity.NodeId,
+ continuity.PreviousIdentity.CredentialId,
+ cancellationToken);
+ if (previous is null)
+ {
+ return MagicProofVerificationResult.Invalid("The previous credential is unknown.");
+ }
+
+ if (previous.Status is not (MagicCredentialStatus.Approved or MagicCredentialStatus.Retiring))
+ {
+ return MagicProofVerificationResult.Invalid("The previous credential is not authorized to rotate.");
+ }
+
+ if (!string.Equals(previous.PublicKey, continuity.PreviousIdentity.PublicKey, StringComparison.Ordinal))
+ {
+ return MagicProofVerificationResult.Invalid("The continuity proof does not match the registered previous key.");
+ }
+
+ await _credentials.UpsertAsync(previous with
+ {
+ Status = MagicCredentialStatus.Retiring,
+ UpdatedUtc = DateTimeOffset.UtcNow
+ }, cancellationToken);
+
+ await _credentials.UpsertAsync(new(
+ continuity.NewIdentity.NodeId,
+ continuity.NewIdentity.CredentialId,
+ continuity.NewIdentity.PublicKey,
+ autoApproveNewCredential ? MagicCredentialStatus.Approved : MagicCredentialStatus.Pending,
+ DateTimeOffset.UtcNow), cancellationToken);
+
+ return MagicProofVerificationResult.Valid;
+ }
+}
+}
+namespace MagicSettings.Server
+{
+public sealed class InMemoryMagicCredentialRegistry : IMagicCredentialRegistry
+{
+ private readonly ConcurrentDictionary<(Guid NodeId, Guid CredentialId), MagicRegisteredCredential> _credentials = new();
+
+ public ValueTask FindAsync(Guid nodeId, Guid credentialId, CancellationToken cancellationToken = default)
+ => ValueTask.FromResult(_credentials.TryGetValue((nodeId, credentialId), out var credential) ? credential : null);
+
+ public ValueTask UpsertAsync(MagicRegisteredCredential credential, CancellationToken cancellationToken = default)
+ {
+ _credentials[(credential.NodeId, credential.CredentialId)] = credential;
+ return ValueTask.CompletedTask;
+ }
+}
+
+public sealed class InMemoryMagicReplayCache : IMagicReplayCache
+{
+ private readonly ConcurrentDictionary _nonces = new(StringComparer.Ordinal);
+
+ public ValueTask TryUseAsync(Guid credentialId, string nonce, DateTimeOffset expiresUtc, CancellationToken cancellationToken = default)
+ {
+ var now = DateTimeOffset.UtcNow;
+ foreach (var pair in _nonces)
+ {
+ if (pair.Value <= now)
+ {
+ _nonces.TryRemove(pair.Key, out _);
+ }
+ }
+
+ return ValueTask.FromResult(_nonces.TryAdd($"{credentialId:D}:{nonce}", expiresUtc));
+ }
+}
+
+public sealed class InMemoryMagicNodeRemoteRecordStore : IMagicNodeRemoteRecordStore
+{
+ private readonly ConcurrentDictionary<(Guid NodeId, string ApplicationId), MagicNodeRemoteRecord> _records = new();
+
+ public ValueTask GetAsync(Guid nodeId, string applicationId, CancellationToken cancellationToken = default)
+ => ValueTask.FromResult(_records.TryGetValue((nodeId, applicationId), out var record) ? record : null);
+
+ public ValueTask SaveAsync(MagicNodeRemoteRecord record, CancellationToken cancellationToken = default)
+ {
+ _records[(record.NodeId, record.ApplicationId)] = record;
+ return ValueTask.CompletedTask;
+ }
+}
+}
+namespace MagicSettings.Server
+{
+public sealed class MagicNodeProofVerifier
+{
+ private readonly IMagicCredentialRegistry _credentials;
+ private readonly IMagicReplayCache _replayCache;
+ private readonly TimeSpan _allowedClockSkew;
+
+ public MagicNodeProofVerifier(IMagicCredentialRegistry credentials, IMagicReplayCache replayCache, TimeSpan? allowedClockSkew = null)
+ {
+ _credentials = credentials;
+ _replayCache = replayCache;
+ _allowedClockSkew = allowedClockSkew ?? TimeSpan.FromSeconds(30);
+ }
+
+ public async ValueTask VerifyAsync(MagicProofVerificationRequest request, CancellationToken cancellationToken = default)
+ {
+ var structural = ValidateRequest(request);
+ if (!structural.IsValid)
+ {
+ return structural;
+ }
+
+ var proof = request.Proof;
+ var credential = await _credentials.FindAsync(proof.NodeId, proof.CredentialId, cancellationToken);
+ if (credential is null)
+ {
+ return MagicProofVerificationResult.Invalid("The credential is unknown.");
+ }
+
+ if (credential.Status is not (MagicCredentialStatus.Approved or MagicCredentialStatus.Retiring))
+ {
+ return MagicProofVerificationResult.Invalid($"The credential is {credential.Status}.");
+ }
+
+ return await VerifySignatureAndReplayAsync(credential.PublicKey, request, cancellationToken);
+ }
+
+ public async ValueTask VerifyEnrollmentAsync(
+ MagicNodeIdentityDescriptor identity,
+ MagicProofVerificationRequest request,
+ CancellationToken cancellationToken = default)
+ {
+ ArgumentNullException.ThrowIfNull(identity);
+ var structural = ValidateRequest(request);
+ if (!structural.IsValid)
+ {
+ return structural;
+ }
+
+ if (request.Proof.NodeId != identity.NodeId || request.Proof.CredentialId != identity.CredentialId)
+ {
+ return MagicProofVerificationResult.Invalid("The proof does not match the supplied node identity.");
+ }
+
+ if (identity.CredentialKind != MagicCredentialKind.EcdsaP256
+ || !string.Equals(identity.SignatureAlgorithm, "ECDSA_P256_SHA256_P1363", StringComparison.Ordinal))
+ {
+ return MagicProofVerificationResult.Invalid("The supplied credential algorithm is unsupported.");
+ }
+
+ string fingerprint;
+ try
+ {
+ fingerprint = Convert.ToHexString(SHA256.HashData(Convert.FromBase64String(identity.PublicKey))).ToLowerInvariant();
+ }
+ catch (FormatException)
+ {
+ return MagicProofVerificationResult.Invalid("The supplied public key is malformed.");
+ }
+
+ if (!CryptographicOperations.FixedTimeEquals(
+ Encoding.ASCII.GetBytes(fingerprint),
+ Encoding.ASCII.GetBytes(identity.Fingerprint.ToLowerInvariant())))
+ {
+ return MagicProofVerificationResult.Invalid("The supplied public-key fingerprint is invalid.");
+ }
+
+ return await VerifySignatureAndReplayAsync(identity.PublicKey, request, cancellationToken);
+ }
+
+ public static bool VerifyContinuity(MagicIdentityContinuityProof proof)
+ {
+ var canonical = string.Join("\n",
+ "MAGICSETTINGS-IDENTITY-CONTINUITY-V1",
+ proof.PreviousIdentity.NodeId.ToString("D"),
+ proof.PreviousIdentity.CredentialId.ToString("D"),
+ proof.NewIdentity.CredentialId.ToString("D"),
+ proof.NewIdentity.PublicKey,
+ proof.IssuedUtc.ToUnixTimeMilliseconds().ToString(System.Globalization.CultureInfo.InvariantCulture),
+ proof.Nonce);
+ return VerifySignature(proof.PreviousIdentity.PublicKey, canonical, proof.Signature)
+ && proof.PreviousIdentity.NodeId == proof.NewIdentity.NodeId;
+ }
+
+ private MagicProofVerificationResult ValidateRequest(MagicProofVerificationRequest request)
+ {
+ ArgumentNullException.ThrowIfNull(request);
+ var proof = request.Proof;
+ if (!string.Equals(proof.Version, "MAGICSETTINGS-PROOF-V1", StringComparison.Ordinal))
+ return MagicProofVerificationResult.Invalid("Unsupported proof version.");
+ if (!string.Equals(proof.Audience, request.ExpectedAudience, StringComparison.Ordinal))
+ return MagicProofVerificationResult.Invalid("The proof audience does not match this API.");
+ if (!string.Equals(proof.Method, request.Method, StringComparison.OrdinalIgnoreCase))
+ return MagicProofVerificationResult.Invalid("The HTTP method does not match the signed proof.");
+ if (!string.Equals(proof.Target, NormalizeTarget(request.Uri), StringComparison.Ordinal))
+ return MagicProofVerificationResult.Invalid("The request target does not match the signed proof.");
+ if (!string.Equals(proof.BodySha256, request.BodySha256, StringComparison.OrdinalIgnoreCase))
+ return MagicProofVerificationResult.Invalid("The request body hash does not match the signed proof.");
+ if (proof.IssuedUtc - _allowedClockSkew > request.NowUtc)
+ return MagicProofVerificationResult.Invalid("The proof was issued in the future.");
+ if (proof.ExpiresUtc + _allowedClockSkew < request.NowUtc)
+ return MagicProofVerificationResult.Invalid("The proof has expired.");
+ if (proof.ExpiresUtc <= proof.IssuedUtc || proof.ExpiresUtc - proof.IssuedUtc > TimeSpan.FromMinutes(5))
+ return MagicProofVerificationResult.Invalid("The proof lifetime is invalid.");
+ if (string.IsNullOrWhiteSpace(proof.Nonce))
+ return MagicProofVerificationResult.Invalid("The proof nonce is missing.");
+ return MagicProofVerificationResult.Valid;
+ }
+
+ private async ValueTask VerifySignatureAndReplayAsync(
+ string publicKey,
+ MagicProofVerificationRequest request,
+ CancellationToken cancellationToken)
+ {
+ var proof = request.Proof;
+ if (!VerifySignature(publicKey, Canonicalize(proof), proof.Signature))
+ {
+ return MagicProofVerificationResult.Invalid("The proof signature is invalid.");
+ }
+
+ if (!await _replayCache.TryUseAsync(proof.CredentialId, proof.Nonce, proof.ExpiresUtc, cancellationToken))
+ {
+ return MagicProofVerificationResult.Invalid("The proof nonce has already been used.");
+ }
+
+ return MagicProofVerificationResult.Valid;
+ }
+
+ private static bool VerifySignature(string publicKey, string canonical, string signature)
+ {
+ try
+ {
+ using var key = ECDsa.Create();
+ key.ImportSubjectPublicKeyInfo(Convert.FromBase64String(publicKey), out _);
+ return key.VerifyData(
+ Encoding.UTF8.GetBytes(canonical),
+ Convert.FromBase64String(signature),
+ HashAlgorithmName.SHA256,
+ DSASignatureFormat.IeeeP1363FixedFieldConcatenation);
+ }
+ catch (CryptographicException)
+ {
+ return false;
+ }
+ catch (FormatException)
+ {
+ return false;
+ }
+ }
+
+ private static string Canonicalize(MagicAuthenticationProof proof)
+ => string.Join("\n",
+ proof.Version,
+ proof.NodeId.ToString("D"),
+ proof.CredentialId.ToString("D"),
+ proof.Audience,
+ proof.Method.ToUpperInvariant(),
+ proof.Target,
+ proof.BodySha256.ToLowerInvariant(),
+ proof.IssuedUtc.ToUnixTimeMilliseconds().ToString(System.Globalization.CultureInfo.InvariantCulture),
+ proof.ExpiresUtc.ToUnixTimeMilliseconds().ToString(System.Globalization.CultureInfo.InvariantCulture),
+ proof.Nonce);
+
+ private static string NormalizeTarget(Uri uri)
+ => uri.IsAbsoluteUri
+ ? uri.GetComponents(UriComponents.SchemeAndServer | UriComponents.PathAndQuery, UriFormat.UriEscaped)
+ : uri.OriginalString;
+}
+}
+namespace MagicSettings.Server
+{
+public interface IMagicSecretResolver
+{
+ ValueTask ResolveAsync(
+ Guid nodeId,
+ string name,
+ CancellationToken cancellationToken = default);
+}
+
+public sealed class MagicSecretService
+{
+ private readonly MagicNodeProofVerifier _proofVerifier;
+ private readonly IMagicSecretResolver _resolver;
+
+ public MagicSecretService(MagicNodeProofVerifier proofVerifier, IMagicSecretResolver resolver)
+ {
+ _proofVerifier = proofVerifier;
+ _resolver = resolver;
+ }
+
+ public async ValueTask ResolveAsync(
+ MagicSecretRequest request,
+ string authorityAudience,
+ Uri requestUri,
+ CancellationToken cancellationToken = default)
+ {
+ ArgumentNullException.ThrowIfNull(request);
+ if (request.NodeId != request.Proof.NodeId || request.CredentialId != request.Proof.CredentialId)
+ {
+ throw new UnauthorizedAccessException("The secret request identity does not match its proof.");
+ }
+
+ var verification = await _proofVerifier.VerifyAsync(
+ new(
+ request.Proof,
+ authorityAudience,
+ "POST",
+ requestUri,
+ MagicSecretProof.ComputeBodySha256(request.Name),
+ DateTimeOffset.UtcNow),
+ cancellationToken);
+ if (!verification.IsValid)
+ {
+ throw new UnauthorizedAccessException(verification.Error);
+ }
+
+ return await _resolver.ResolveAsync(request.NodeId, request.Name, cancellationToken);
+ }
+}
+}
diff --git a/MagicSettings/MagicSettings.Server/Source.02.cs b/MagicSettings/MagicSettings.Server/Source.02.cs
new file mode 100644
index 0000000..a937d12
--- /dev/null
+++ b/MagicSettings/MagicSettings.Server/Source.02.cs
@@ -0,0 +1,164 @@
+// Consolidated source file. See repository history and wiki for subsystem boundaries.
+using System.Security.Cryptography;
+using MagicSettings.Share;
+using Microsoft.AspNetCore.Http;
+using System.Collections.Concurrent;
+using System.Text;
+
+namespace MagicSettings.Server
+{
+public sealed record MagicRegisteredCredential(
+ Guid NodeId,
+ Guid CredentialId,
+ string PublicKey,
+ MagicCredentialStatus Status,
+ DateTimeOffset UpdatedUtc);
+
+public interface IMagicCredentialRegistry
+{
+ ValueTask FindAsync(Guid nodeId, Guid credentialId, CancellationToken cancellationToken = default);
+ ValueTask UpsertAsync(MagicRegisteredCredential credential, CancellationToken cancellationToken = default);
+}
+
+public interface IMagicReplayCache
+{
+ ValueTask TryUseAsync(Guid credentialId, string nonce, DateTimeOffset expiresUtc, CancellationToken cancellationToken = default);
+}
+
+public sealed record MagicNodeRemoteRecord(
+ Guid NodeId,
+ string ApplicationId,
+ int SchemaVersion,
+ string SchemaFingerprint,
+ long Revision,
+ MagicRemoteSnapshot Snapshot,
+ IReadOnlyList PendingReviewItems,
+ DateTimeOffset LastContactUtc);
+
+public interface IMagicNodeRemoteRecordStore
+{
+ ValueTask GetAsync(Guid nodeId, string applicationId, CancellationToken cancellationToken = default);
+ ValueTask SaveAsync(MagicNodeRemoteRecord record, CancellationToken cancellationToken = default);
+}
+}
+namespace MagicSettings.Server
+{
+public sealed class MagicSettingsSyncService
+{
+ private readonly IMagicCredentialRegistry _credentials;
+ private readonly IMagicNodeRemoteRecordStore _records;
+ private readonly MagicNodeProofVerifier _proofVerifier;
+ private readonly MagicCredentialRotationService _rotationService;
+ private readonly bool _autoApproveRotatedCredentials;
+
+ public MagicSettingsSyncService(
+ IMagicCredentialRegistry credentials,
+ IMagicNodeRemoteRecordStore records,
+ MagicNodeProofVerifier proofVerifier,
+ bool autoApproveRotatedCredentials = false)
+ {
+ _credentials = credentials;
+ _records = records;
+ _proofVerifier = proofVerifier;
+ _rotationService = new MagicCredentialRotationService(credentials);
+ _autoApproveRotatedCredentials = autoApproveRotatedCredentials;
+ }
+
+ public async ValueTask SynchronizeAsync(
+ MagicSettingsSyncRequest request,
+ string authorityAudience,
+ Uri requestUri,
+ CancellationToken cancellationToken = default)
+ {
+ ArgumentNullException.ThrowIfNull(request);
+ var signedPayloadHash = MagicSettingsSyncProof.ComputeBodySha256(
+ request.Identity,
+ request.Manifest,
+ request.LastRemoteRevision,
+ request.MigrationReport,
+ request.IdentityContinuityProof);
+ var verificationRequest = new MagicProofVerificationRequest(
+ request.Proof,
+ authorityAudience,
+ "POST",
+ requestUri,
+ signedPayloadHash,
+ DateTimeOffset.UtcNow);
+
+ var registered = await _credentials.FindAsync(request.Identity.NodeId, request.Identity.CredentialId, cancellationToken);
+ if (registered is null && request.IdentityContinuityProof is { } continuity)
+ {
+ if (continuity.NewIdentity.NodeId != request.Identity.NodeId
+ || continuity.NewIdentity.CredentialId != request.Identity.CredentialId
+ || !string.Equals(continuity.NewIdentity.PublicKey, request.Identity.PublicKey, StringComparison.Ordinal))
+ {
+ return new(MagicControlPlaneState.Faulted, MagicRemoteSnapshot.Empty, "The rotation proof does not describe the supplied current identity.");
+ }
+
+ var rotation = await _rotationService.ApplyAsync(continuity, _autoApproveRotatedCredentials, cancellationToken);
+ if (!rotation.IsValid)
+ {
+ return new(MagicControlPlaneState.Faulted, MagicRemoteSnapshot.Empty, rotation.Error);
+ }
+
+ registered = await _credentials.FindAsync(request.Identity.NodeId, request.Identity.CredentialId, cancellationToken);
+ }
+
+ if (registered is null)
+ {
+ var enrollmentVerification = await _proofVerifier.VerifyEnrollmentAsync(request.Identity, verificationRequest, cancellationToken);
+ if (!enrollmentVerification.IsValid)
+ {
+ return new(MagicControlPlaneState.Faulted, MagicRemoteSnapshot.Empty, enrollmentVerification.Error);
+ }
+
+ await _credentials.UpsertAsync(
+ new(request.Identity.NodeId, request.Identity.CredentialId, request.Identity.PublicKey, MagicCredentialStatus.Pending, DateTimeOffset.UtcNow),
+ cancellationToken);
+ return new(MagicControlPlaneState.PendingApproval, MagicRemoteSnapshot.Empty, "Node credential is pending approval.");
+ }
+
+ if (registered.Status == MagicCredentialStatus.Pending)
+ {
+ return new(MagicControlPlaneState.PendingApproval, MagicRemoteSnapshot.Empty, "Node credential is pending approval.");
+ }
+
+ if (!string.Equals(registered.PublicKey, request.Identity.PublicKey, StringComparison.Ordinal))
+ {
+ return new(MagicControlPlaneState.Faulted, MagicRemoteSnapshot.Empty, "The supplied identity does not match the registered credential.");
+ }
+
+ var verification = await _proofVerifier.VerifyAsync(verificationRequest, cancellationToken);
+ if (!verification.IsValid)
+ {
+ return new(MagicControlPlaneState.Faulted, MagicRemoteSnapshot.Empty, verification.Error);
+ }
+
+ var existing = await _records.GetAsync(request.Identity.NodeId, request.Manifest.ApplicationId, cancellationToken);
+ var reviewItems = existing?.PendingReviewItems.ToList() ?? new List();
+ if (request.MigrationReport is { } migrationReport)
+ {
+ foreach (var item in migrationReport.ReviewItems)
+ {
+ if (!reviewItems.Contains(item))
+ {
+ reviewItems.Add(item);
+ }
+ }
+ }
+
+ var snapshot = existing?.Snapshot ?? MagicRemoteSnapshot.Empty;
+ var updated = new MagicNodeRemoteRecord(
+ request.Identity.NodeId,
+ request.Manifest.ApplicationId,
+ request.Manifest.SchemaVersion,
+ request.Manifest.SchemaFingerprint,
+ existing?.Revision ?? 0,
+ snapshot,
+ reviewItems,
+ DateTimeOffset.UtcNow);
+ await _records.SaveAsync(updated, cancellationToken);
+ return new(MagicControlPlaneState.Active, snapshot);
+ }
+}
+}
diff --git a/MagicSettings/MagicSettings.Share/ConfigurationContracts.cs b/MagicSettings/MagicSettings.Share/ConfigurationContracts.cs
new file mode 100644
index 0000000..996d27c
--- /dev/null
+++ b/MagicSettings/MagicSettings.Share/ConfigurationContracts.cs
@@ -0,0 +1,73 @@
+using System.Collections.ObjectModel;
+using System.Text.Json;
+
+namespace MagicSettings.Share;
+
+public enum MagicValueState
+{
+ Value,
+ Null
+}
+
+public enum MagicRemoteValueDurability
+{
+ Sticky,
+ Refreshable,
+ Expiring
+}
+
+public sealed record MagicRemoteValue(
+ MagicValueState State,
+ JsonElement? Value,
+ MagicRemoteValueDurability Durability = MagicRemoteValueDurability.Sticky,
+ DateTimeOffset? ExpiresUtc = null)
+{
+ public static MagicRemoteValue From(T value, MagicRemoteValueDurability durability = MagicRemoteValueDurability.Sticky, DateTimeOffset? expiresUtc = null)
+ => new(MagicValueState.Value, JsonSerializer.SerializeToElement(value), durability, expiresUtc);
+
+ public static MagicRemoteValue ExplicitNull(MagicRemoteValueDurability durability = MagicRemoteValueDurability.Sticky, DateTimeOffset? expiresUtc = null)
+ => new(MagicValueState.Null, null, durability, expiresUtc);
+}
+
+public sealed record MagicRemoteSnapshot(
+ long Revision,
+ DateTimeOffset IssuedUtc,
+ IReadOnlyDictionary Values)
+{
+ public static MagicRemoteSnapshot Empty { get; } = new(0, DateTimeOffset.MinValue, new ReadOnlyDictionary(new Dictionary()));
+}
+
+public sealed record MagicSettingManifestEntry(
+ string Path,
+ string Type,
+ bool Nullable,
+ bool Sensitive,
+ bool RemoteOverrideAllowed,
+ string? Description = null);
+
+public sealed record MagicSettingsSchemaManifest(
+ string ApplicationId,
+ string ApplicationVersion,
+ int SchemaVersion,
+ string SchemaFingerprint,
+ IReadOnlyList Settings);
+
+public enum MagicMigrationReviewSeverity
+{
+ Information,
+ Warning,
+ Destructive
+}
+
+public sealed record MagicMigrationReviewItem(
+ string Path,
+ string Operation,
+ MagicMigrationReviewSeverity Severity,
+ string Reason,
+ string? ProposedPath = null);
+
+public sealed record MagicSettingsMigrationReport(
+ int FromVersion,
+ int ToVersion,
+ IReadOnlyList SafeOperations,
+ IReadOnlyList ReviewItems);
diff --git a/MagicSettings/MagicSettings.Share/ControlPlaneContracts.cs b/MagicSettings/MagicSettings.Share/ControlPlaneContracts.cs
new file mode 100644
index 0000000..ff19f55
--- /dev/null
+++ b/MagicSettings/MagicSettings.Share/ControlPlaneContracts.cs
@@ -0,0 +1,58 @@
+namespace MagicSettings.Share;
+
+public enum MagicControlPlaneEndpointSource
+{
+ None,
+ CodeFallback,
+ PersistentSettings,
+ EnvironmentVariable,
+ RuntimeOverride
+}
+
+public sealed record MagicResolvedControlPlaneEndpoint(Uri? Endpoint, MagicControlPlaneEndpointSource Source)
+{
+ public static MagicResolvedControlPlaneEndpoint None { get; } = new(null, MagicControlPlaneEndpointSource.None);
+}
+
+public enum MagicControlPlaneState
+{
+ Disabled,
+ Configured,
+ Connecting,
+ PendingApproval,
+ Active,
+ Disconnected,
+ Faulted
+}
+
+public enum MagicControlPlaneTrustMode
+{
+ SystemTls,
+ PinnedPublicKey
+}
+
+public sealed record MagicControlPlaneTrust(
+ MagicControlPlaneTrustMode Mode,
+ string AuthorityId,
+ string? PinnedPublicKeyFingerprint = null)
+{
+ public static MagicControlPlaneTrust SystemTls(string authorityId) => new(MagicControlPlaneTrustMode.SystemTls, authorityId);
+ public static MagicControlPlaneTrust Pinned(string authorityId, string fingerprint) => new(MagicControlPlaneTrustMode.PinnedPublicKey, authorityId, fingerprint);
+}
+
+public sealed record MagicSettingsSyncRequest(
+ MagicNodeIdentityDescriptor Identity,
+ MagicAuthenticationProof Proof,
+ MagicSettingsSchemaManifest Manifest,
+ long LastRemoteRevision,
+ MagicSettingsMigrationReport? MigrationReport = null,
+ MagicIdentityContinuityProof? IdentityContinuityProof = null);
+
+public sealed record MagicSettingsSyncResponse(
+ MagicControlPlaneState State,
+ MagicRemoteSnapshot Snapshot,
+ string? Message = null,
+ TimeSpan? SuggestedPollInterval = null);
+
+public sealed record MagicSecretRequest(Guid NodeId, Guid CredentialId, string Name, MagicAuthenticationProof Proof);
+public sealed record MagicSecretResponse(bool Found, string? Value, DateTimeOffset? ExpiresUtc = null);
diff --git a/MagicSettings/MagicSettings.Share/MagicSettings.Share.csproj b/MagicSettings/MagicSettings.Share/MagicSettings.Share.csproj
index 237d661..ecaa002 100644
--- a/MagicSettings/MagicSettings.Share/MagicSettings.Share.csproj
+++ b/MagicSettings/MagicSettings.Share/MagicSettings.Share.csproj
@@ -1,9 +1,6 @@
-
-
-
- net10.0
- enable
- enable
-
-
+
+
+ MagicSettings.Share
+ Shared protocol, schema, and security contracts for MagicSettings clients and control-plane implementations.
+
diff --git a/MagicSettings/MagicSettings.Share/ProofCodec.cs b/MagicSettings/MagicSettings.Share/ProofCodec.cs
new file mode 100644
index 0000000..09dba9e
--- /dev/null
+++ b/MagicSettings/MagicSettings.Share/ProofCodec.cs
@@ -0,0 +1,26 @@
+using System.Text;
+using System.Text.Json;
+
+namespace MagicSettings.Share;
+
+public static class MagicNodeProofCodec
+{
+ private static readonly JsonSerializerOptions Json = new(JsonSerializerDefaults.Web);
+
+ public static string Encode(MagicAuthenticationProof proof)
+ => Base64UrlEncode(JsonSerializer.SerializeToUtf8Bytes(proof, Json));
+
+ public static MagicAuthenticationProof Decode(string encoded)
+ => JsonSerializer.Deserialize(Base64UrlDecode(encoded), Json)
+ ?? throw new FormatException("The MagicSettings authentication proof is invalid.");
+
+ public static string Base64UrlEncode(ReadOnlySpan value)
+ => Convert.ToBase64String(value).TrimEnd('=').Replace('+', '-').Replace('/', '_');
+
+ public static byte[] Base64UrlDecode(string value)
+ {
+ var padded = value.Replace('-', '+').Replace('_', '/');
+ padded += new string('=', (4 - padded.Length % 4) % 4);
+ return Convert.FromBase64String(padded);
+ }
+}
diff --git a/MagicSettings/MagicSettings.Share/SecretProof.cs b/MagicSettings/MagicSettings.Share/SecretProof.cs
new file mode 100644
index 0000000..de4b359
--- /dev/null
+++ b/MagicSettings/MagicSettings.Share/SecretProof.cs
@@ -0,0 +1,13 @@
+using System.Security.Cryptography;
+using System.Text;
+
+namespace MagicSettings.Share;
+
+public static class MagicSecretProof
+{
+ public static string ComputeBodySha256(string name)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(name);
+ return Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(name))).ToLowerInvariant();
+ }
+}
diff --git a/MagicSettings/MagicSettings.Share/SecurityContracts.cs b/MagicSettings/MagicSettings.Share/SecurityContracts.cs
new file mode 100644
index 0000000..d158f4a
--- /dev/null
+++ b/MagicSettings/MagicSettings.Share/SecurityContracts.cs
@@ -0,0 +1,80 @@
+namespace MagicSettings.Share;
+
+public enum MagicCredentialKind
+{
+ EcdsaP256
+}
+
+public enum MagicCredentialStatus
+{
+ Pending,
+ Approved,
+ Retiring,
+ Revoked
+}
+
+public sealed record MagicNodeIdentityDescriptor(
+ Guid NodeId,
+ Guid CredentialId,
+ MagicCredentialKind CredentialKind,
+ string SignatureAlgorithm,
+ string PublicKey,
+ string Fingerprint,
+ DateTimeOffset CreatedUtc);
+
+public sealed record MagicAuthenticationRequest(
+ string Audience,
+ string Method,
+ Uri Uri,
+ string BodySha256,
+ TimeSpan? ValidFor = null);
+
+public sealed record MagicAuthenticationProof(
+ string Version,
+ Guid NodeId,
+ Guid CredentialId,
+ string Audience,
+ string Method,
+ string Target,
+ string BodySha256,
+ DateTimeOffset IssuedUtc,
+ DateTimeOffset ExpiresUtc,
+ string Nonce,
+ string Signature);
+
+public sealed record MagicIdentityContinuityProof(
+ MagicNodeIdentityDescriptor PreviousIdentity,
+ MagicNodeIdentityDescriptor NewIdentity,
+ DateTimeOffset IssuedUtc,
+ string Nonce,
+ string Signature);
+
+public enum MagicIdentityChangeKind
+{
+ Rotated,
+ Reset,
+ RecoveredAfterLoss
+}
+
+public sealed record MagicIdentityChange(
+ MagicIdentityChangeKind Kind,
+ MagicNodeIdentityDescriptor Current,
+ MagicNodeIdentityDescriptor? Previous,
+ MagicIdentityContinuityProof? ContinuityProof,
+ string Reason);
+
+public sealed record MagicIdentityResetRequest(string Reason, bool ConfirmDestructiveReset);
+
+public sealed record MagicProofVerificationRequest(
+ MagicAuthenticationProof Proof,
+ string ExpectedAudience,
+ string Method,
+ Uri Uri,
+ string BodySha256,
+ DateTimeOffset NowUtc);
+
+public sealed record MagicProofVerificationResult(bool IsValid, string? Error)
+{
+ public static MagicProofVerificationResult Valid { get; } = new(true, null);
+ public static MagicProofVerificationResult Invalid(string error) => new(false, error);
+}
diff --git a/MagicSettings/MagicSettings.Share/SyncProof.cs b/MagicSettings/MagicSettings.Share/SyncProof.cs
new file mode 100644
index 0000000..5e92a1a
--- /dev/null
+++ b/MagicSettings/MagicSettings.Share/SyncProof.cs
@@ -0,0 +1,50 @@
+using System.Security.Cryptography;
+using System.Text.Json;
+
+namespace MagicSettings.Share;
+
+///
+/// Produces the deterministic payload hash signed for a control-plane synchronization request.
+/// The proof itself is intentionally excluded so the request can be signed without a circular dependency.
+///
+public static class MagicSettingsSyncProof
+{
+ private static readonly JsonSerializerOptions Json = new(JsonSerializerDefaults.Web);
+
+ public static string ComputeBodySha256(
+ MagicNodeIdentityDescriptor identity,
+ MagicSettingsSchemaManifest manifest,
+ long lastRemoteRevision,
+ MagicSettingsMigrationReport? migrationReport,
+ MagicIdentityContinuityProof? identityContinuityProof = null)
+ {
+ ArgumentNullException.ThrowIfNull(identity);
+ ArgumentNullException.ThrowIfNull(manifest);
+
+ var payload = new MagicSettingsSyncSigningPayload(
+ identity.NodeId,
+ identity.CredentialId,
+ identity.PublicKey,
+ manifest.ApplicationId,
+ manifest.ApplicationVersion,
+ manifest.SchemaVersion,
+ manifest.SchemaFingerprint,
+ lastRemoteRevision,
+ migrationReport,
+ identityContinuityProof);
+
+ return Convert.ToHexString(SHA256.HashData(JsonSerializer.SerializeToUtf8Bytes(payload, Json))).ToLowerInvariant();
+ }
+
+ private sealed record MagicSettingsSyncSigningPayload(
+ Guid NodeId,
+ Guid CredentialId,
+ string PublicKey,
+ string ApplicationId,
+ string ApplicationVersion,
+ int SchemaVersion,
+ string SchemaFingerprint,
+ long LastRemoteRevision,
+ MagicSettingsMigrationReport? MigrationReport,
+ MagicIdentityContinuityProof? IdentityContinuityProof);
+}
diff --git a/MagicSettings/MagicSettings.Tests/MagicSettings.Tests.csproj b/MagicSettings/MagicSettings.Tests/MagicSettings.Tests.csproj
index 68b9f9b..eee3404 100644
--- a/MagicSettings/MagicSettings.Tests/MagicSettings.Tests.csproj
+++ b/MagicSettings/MagicSettings.Tests/MagicSettings.Tests.csproj
@@ -1,23 +1,24 @@
-
-
-
- net10.0
- latest
- enable
- enable
- false
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+ false
+ true
+ false
+ false
+
+
+
+
+
+ all
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+
+
+
+
+
+
+
+
+
+
diff --git a/MagicSettings/MagicSettings.Tests/Source.01.cs b/MagicSettings/MagicSettings.Tests/Source.01.cs
new file mode 100644
index 0000000..d5fc0ec
--- /dev/null
+++ b/MagicSettings/MagicSettings.Tests/Source.01.cs
@@ -0,0 +1,256 @@
+// Consolidated source file. See repository history and wiki for subsystem boundaries.
+global using Xunit;
+using MagicSettings.Server;
+using MagicSettings.Share;
+using System.Text.Json.Nodes;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Hosting;
+
+namespace MagicSettings.Tests
+{
+public sealed class CredentialAdministrationTests
+{
+ [Fact]
+ public async Task RevokedCredentialCanNoLongerAuthenticate()
+ {
+ using var directory = new TemporaryDirectory();
+ var manager = new MagicNodeIdentityManager(new FileMagicNodeIdentityStore(Path.Combine(directory.Path, "identity.json")));
+ var identity = await manager.GetCurrentAsync();
+ var registry = new InMemoryMagicCredentialRegistry();
+ await registry.UpsertAsync(new(identity.NodeId, identity.CredentialId, identity.PublicKey, MagicCredentialStatus.Approved, DateTimeOffset.UtcNow));
+ var administration = new MagicCredentialAdministrationService(registry);
+ Assert.True(await administration.RevokeAsync(identity.NodeId, identity.CredentialId));
+
+ var uri = new Uri("https://api.example/resource");
+ var proof = await manager.CreateProofAsync(new("Resource.Api", "GET", uri, MagicHash.EmptySha256));
+ var result = await new MagicNodeProofVerifier(registry, new InMemoryMagicReplayCache()).VerifyAsync(
+ new(proof, "Resource.Api", "GET", uri, MagicHash.EmptySha256, DateTimeOffset.UtcNow));
+
+ Assert.False(result.IsValid);
+ Assert.Equal("The credential is Revoked.", result.Error);
+ }
+}
+}
+namespace MagicSettings.Tests
+{
+public sealed class CredentialRotationTests
+{
+ [Fact]
+ public async Task ServerCanApproveRotatedCredentialWithoutReceivingPrivateKey()
+ {
+ using var directory = new TemporaryDirectory();
+ var manager = new MagicNodeIdentityManager(new FileMagicNodeIdentityStore(Path.Combine(directory.Path, "identity.json")));
+ var initial = await manager.GetCurrentAsync();
+ var registry = new InMemoryMagicCredentialRegistry();
+ await registry.UpsertAsync(new(initial.NodeId, initial.CredentialId, initial.PublicKey, MagicCredentialStatus.Approved, DateTimeOffset.UtcNow));
+ var rotation = await manager.RotateAsync("scheduled");
+
+ var result = await new MagicCredentialRotationService(registry).ApplyAsync(rotation.ContinuityProof!, autoApproveNewCredential: true);
+ var oldCredential = await registry.FindAsync(initial.NodeId, initial.CredentialId);
+ var newCredential = await registry.FindAsync(rotation.Current.NodeId, rotation.Current.CredentialId);
+
+ Assert.True(result.IsValid);
+ Assert.Equal(MagicCredentialStatus.Retiring, oldCredential!.Status);
+ Assert.Equal(MagicCredentialStatus.Approved, newCredential!.Status);
+ }
+ [Fact]
+ public async Task SyncCanCarryContinuityProofAndAutoApproveRotatedCredential()
+ {
+ using var directory = new TemporaryDirectory();
+ var manager = new MagicNodeIdentityManager(new FileMagicNodeIdentityStore(Path.Combine(directory.Path, "identity.json")));
+ var initial = await manager.GetCurrentAsync();
+ var registry = new InMemoryMagicCredentialRegistry();
+ await registry.UpsertAsync(new(initial.NodeId, initial.CredentialId, initial.PublicKey, MagicCredentialStatus.Approved, DateTimeOffset.UtcNow));
+ var change = await manager.RotateAsync("scheduled");
+ var records = new InMemoryMagicNodeRemoteRecordStore();
+ var verifier = new MagicNodeProofVerifier(registry, new InMemoryMagicReplayCache());
+ var service = new MagicSettingsSyncService(registry, records, verifier, autoApproveRotatedCredentials: true);
+ var uri = new Uri("https://control.example/magicsettings/sync");
+ var manifest = new MagicSettingsSchemaManifest("TestApp", "1.0.0", 1, "fingerprint", []);
+ var bodyHash = MagicSettingsSyncProof.ComputeBodySha256(change.Current, manifest, 0, null, change.ContinuityProof);
+ var proof = await manager.CreateProofAsync(new("Control.Api", "POST", uri, bodyHash));
+
+ var response = await service.SynchronizeAsync(
+ new(change.Current, proof, manifest, 0, null, change.ContinuityProof),
+ "Control.Api",
+ uri);
+
+ Assert.Equal(MagicControlPlaneState.Active, response.State);
+ Assert.Equal(
+ MagicCredentialStatus.Approved,
+ (await registry.FindAsync(change.Current.NodeId, change.Current.CredentialId))!.Status);
+ }
+
+}
+}
+namespace MagicSettings.Tests
+{
+public sealed class EndpointBootstrapTests
+{
+ [Fact]
+ public void Resolver_UsesEnvironmentBeforePersistentAndFallback()
+ {
+ const string variable = "MAGICSETTINGS_TEST_CONTROL_PLANE";
+ var previous = Environment.GetEnvironmentVariable(variable);
+ try
+ {
+ Environment.SetEnvironmentVariable(variable, "https://environment.example/");
+ var options = new MagicSettingsOptions();
+ options.ControlPlane.Bootstrap.EnvironmentVariableName = variable;
+ options.ControlPlane.Bootstrap.PersistentSettingPath = "MagicSettings:ControlPlane:Endpoint";
+ options.ControlPlane.Bootstrap.CodeFallbackEndpoint = new Uri("https://fallback.example/");
+ var document = JsonNode.Parse("""{"MagicSettings":{"ControlPlane":{"Endpoint":"https://file.example/"}}}""")!.AsObject();
+
+ var resolved = new MagicControlPlaneEndpointResolver().Resolve(options, document);
+
+ Assert.Equal(MagicControlPlaneEndpointSource.EnvironmentVariable, resolved.Source);
+ Assert.Equal("https://environment.example/", resolved.Endpoint!.AbsoluteUri);
+ }
+ finally
+ {
+ Environment.SetEnvironmentVariable(variable, previous);
+ }
+ }
+
+ [Fact]
+ public void Resolver_UsesPersistentFileWithoutConsultingRemoteState()
+ {
+ var options = new MagicSettingsOptions();
+ options.ControlPlane.Bootstrap.EnvironmentVariableName = "MAGICSETTINGS_TEST_MISSING_ENDPOINT";
+ options.ControlPlane.Bootstrap.PersistentSettingPath = "MagicSettings:ControlPlane:Endpoint";
+ var local = JsonNode.Parse("""{"MagicSettings":{"ControlPlane":{"Endpoint":"https://local.example/"}}}""")!.AsObject();
+
+ var resolved = new MagicControlPlaneEndpointResolver().Resolve(options, local);
+
+ Assert.Equal(MagicControlPlaneEndpointSource.PersistentSettings, resolved.Source);
+ Assert.Equal("https://local.example/", resolved.Endpoint!.AbsoluteUri);
+ }
+
+ [Fact]
+ public void Resolver_RejectsNonLoopbackHttpUnlessExplicitlyAllowed()
+ {
+ var options = new MagicSettingsOptions();
+ options.ControlPlane.Bootstrap.CodeFallbackEndpoint = new Uri("http://control.example/");
+
+ Assert.Throws(() =>
+ new MagicControlPlaneEndpointResolver().Resolve(options, new JsonObject()));
+
+ options.ControlPlane.Bootstrap.AllowInsecureHttp = true;
+ var resolved = new MagicControlPlaneEndpointResolver().Resolve(options, new JsonObject());
+ Assert.Equal("http://control.example/", resolved.Endpoint!.AbsoluteUri);
+ }
+
+ [Fact]
+ public void RuntimeOverride_IsHighestPriority()
+ {
+ var options = new MagicSettingsOptions();
+ options.ControlPlane.Bootstrap.CodeFallbackEndpoint = new Uri("https://fallback.example/");
+ var resolved = new MagicControlPlaneEndpointResolver().Resolve(options, new JsonObject(), new Uri("https://runtime.example/"));
+ Assert.Equal(MagicControlPlaneEndpointSource.RuntimeOverride, resolved.Source);
+ Assert.Equal("https://runtime.example/", resolved.Endpoint!.AbsoluteUri);
+ }
+}
+}
+namespace MagicSettings.Tests
+{
+public sealed class EnrollmentTests
+{
+ [Fact]
+ public async Task UnknownNodeMustProvePossessionBeforeBeingRegisteredAsPending()
+ {
+ using var directory = new TemporaryDirectory();
+ var manager = new MagicNodeIdentityManager(new FileMagicNodeIdentityStore(Path.Combine(directory.Path, "identity.json")));
+ var identity = await manager.GetCurrentAsync();
+ var registry = new InMemoryMagicCredentialRegistry();
+ var service = new MagicSettingsSyncService(
+ registry,
+ new InMemoryMagicNodeRemoteRecordStore(),
+ new MagicNodeProofVerifier(registry, new InMemoryMagicReplayCache()));
+ var uri = new Uri("https://control.example/magicsettings/sync");
+ var manifest = new MagicSettingsSchemaManifest("TestApp", "1.0.0", 1, "fingerprint", []);
+ var bodyHash = MagicSettingsSyncProof.ComputeBodySha256(identity, manifest, 0, null);
+ var proof = await manager.CreateProofAsync(new("Control.Api", "POST", uri, bodyHash));
+
+ var response = await service.SynchronizeAsync(new(identity, proof, manifest, 0), "Control.Api", uri);
+ var registered = await registry.FindAsync(identity.NodeId, identity.CredentialId);
+
+ Assert.Equal(MagicControlPlaneState.PendingApproval, response.State);
+ Assert.Equal(MagicCredentialStatus.Pending, registered!.Status);
+ }
+
+ [Fact]
+ public async Task UnknownNodeWithTamperedManifestIsRejected()
+ {
+ using var directory = new TemporaryDirectory();
+ var manager = new MagicNodeIdentityManager(new FileMagicNodeIdentityStore(Path.Combine(directory.Path, "identity.json")));
+ var identity = await manager.GetCurrentAsync();
+ var registry = new InMemoryMagicCredentialRegistry();
+ var service = new MagicSettingsSyncService(
+ registry,
+ new InMemoryMagicNodeRemoteRecordStore(),
+ new MagicNodeProofVerifier(registry, new InMemoryMagicReplayCache()));
+ var uri = new Uri("https://control.example/magicsettings/sync");
+ var signedManifest = new MagicSettingsSchemaManifest("TestApp", "1.0.0", 1, "fingerprint-a", []);
+ var sentManifest = signedManifest with { SchemaFingerprint = "fingerprint-b" };
+ var bodyHash = MagicSettingsSyncProof.ComputeBodySha256(identity, signedManifest, 0, null);
+ var proof = await manager.CreateProofAsync(new("Control.Api", "POST", uri, bodyHash));
+
+ var response = await service.SynchronizeAsync(new(identity, proof, sentManifest, 0), "Control.Api", uri);
+
+ Assert.Equal(MagicControlPlaneState.Faulted, response.State);
+ Assert.Null(await registry.FindAsync(identity.NodeId, identity.CredentialId));
+ }
+}
+}
+namespace MagicSettings.Tests
+{
+public sealed class IdentityTests
+{
+ [Fact]
+ public async Task Identity_IsStableAcrossManagerInstances()
+ {
+ using var directory = new TemporaryDirectory();
+ var path = Path.Combine(directory.Path, "identity.json");
+ var first = new MagicNodeIdentityManager(new FileMagicNodeIdentityStore(path));
+ var firstIdentity = await first.GetCurrentAsync();
+ var second = new MagicNodeIdentityManager(new FileMagicNodeIdentityStore(path));
+ var secondIdentity = await second.GetCurrentAsync();
+
+ Assert.Equal(firstIdentity.NodeId, secondIdentity.NodeId);
+ Assert.Equal(firstIdentity.CredentialId, secondIdentity.CredentialId);
+ Assert.Equal(firstIdentity.Fingerprint, secondIdentity.Fingerprint);
+ }
+
+ [Fact]
+ public async Task Rotation_KeepsNodeAndProducesVerifiableContinuityProof()
+ {
+ using var directory = new TemporaryDirectory();
+ var manager = new MagicNodeIdentityManager(new FileMagicNodeIdentityStore(Path.Combine(directory.Path, "identity.json")));
+ var before = await manager.GetCurrentAsync();
+
+ var change = await manager.RotateAsync("scheduled rotation");
+
+ Assert.Equal(MagicIdentityChangeKind.Rotated, change.Kind);
+ Assert.Equal(before.NodeId, change.Current.NodeId);
+ Assert.NotEqual(before.CredentialId, change.Current.CredentialId);
+ Assert.NotNull(change.ContinuityProof);
+ Assert.True(MagicNodeProofVerifier.VerifyContinuity(change.ContinuityProof!));
+ }
+
+ [Fact]
+ public async Task Reset_ChangesNodeAndRequiresExplicitConfirmation()
+ {
+ using var directory = new TemporaryDirectory();
+ var manager = new MagicNodeIdentityManager(new FileMagicNodeIdentityStore(Path.Combine(directory.Path, "identity.json")));
+ var before = await manager.GetCurrentAsync();
+
+ await Assert.ThrowsAsync(async () =>
+ await manager.ResetAsync(new("test", false)));
+
+ var change = await manager.ResetAsync(new("compromised", true));
+ Assert.Equal(MagicIdentityChangeKind.Reset, change.Kind);
+ Assert.NotEqual(before.NodeId, change.Current.NodeId);
+ Assert.Null(change.ContinuityProof);
+ }
+}
+}
diff --git a/MagicSettings/MagicSettings.Tests/Source.02.cs b/MagicSettings/MagicSettings.Tests/Source.02.cs
new file mode 100644
index 0000000..fc75ce7
--- /dev/null
+++ b/MagicSettings/MagicSettings.Tests/Source.02.cs
@@ -0,0 +1,378 @@
+// Consolidated source file. See repository history and wiki for subsystem boundaries.
+global using Xunit;
+using MagicSettings.Server;
+using MagicSettings.Share;
+using System.Text.Json.Nodes;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Hosting;
+
+namespace MagicSettings.Tests
+{
+public sealed class InitializationTests
+{
+ [Fact]
+ public async Task Initialization_GeneratesFileAndPreservesExistingValuesWhileAddingDefaults()
+ {
+ using var directory = new TemporaryDirectory();
+ var path = Path.Combine(directory.Path, "settings.json");
+ var builder = new HostApplicationBuilder();
+ await builder.AddMagicSettingsAsync(configure: options =>
+ {
+ options.Path = path;
+ options.Template.Database.Host = "template-host";
+ options.Template.Database.Port = 5432;
+ });
+
+ var document = JsonNode.Parse(await File.ReadAllTextAsync(path))!.AsObject();
+ document["Database"]!["Host"] = "operator-host";
+ document["Database"]!.AsObject().Remove("Port");
+ await File.WriteAllTextAsync(path, document.ToJsonString(new() { WriteIndented = true }));
+
+ var secondBuilder = new HostApplicationBuilder();
+ await secondBuilder.AddMagicSettingsAsync(configure: options =>
+ {
+ options.Path = path;
+ options.Template.Database.Host = "new-template-host";
+ options.Template.Database.Port = 7777;
+ });
+
+ var reconciled = JsonNode.Parse(await File.ReadAllTextAsync(path))!.AsObject();
+ Assert.Equal("operator-host", reconciled["Database"]!["Host"]!.GetValue());
+ Assert.Equal(7777, reconciled["Database"]!["Port"]!.GetValue());
+ }
+
+ [Fact]
+ public async Task RemoteSnapshot_OverridesEnvironmentAndFileWithoutPersisting()
+ {
+ using var directory = new TemporaryDirectory();
+ var path = Path.Combine(directory.Path, "settings.json");
+ var transport = new FakeTransport("Database:Host", MagicRemoteValue.From("remote-host"));
+ var previous = Environment.GetEnvironmentVariable("MagicSettings__Database__Host");
+ Environment.SetEnvironmentVariable("MagicSettings__Database__Host", "environment-host");
+ try
+ {
+ var builder = new HostApplicationBuilder();
+ await builder.AddMagicSettingsAsync(configure: options =>
+ {
+ options.Path = path;
+ options.Template.Database.Host = "file-host";
+ options.ControlPlaneTransport = transport;
+ });
+ await using var provider = builder.Services.BuildServiceProvider();
+ var controlPlane = provider.GetRequiredService();
+ await controlPlane.ConfigureAsync(new Uri("https://control.example/"), MagicControlPlaneTrust.SystemTls("Control.Api"));
+ var settings = provider.GetRequiredService>();
+
+ Assert.Equal("remote-host", settings.Current.Database.Host);
+ var file = JsonNode.Parse(await File.ReadAllTextAsync(path))!.AsObject();
+ Assert.Equal("file-host", file["Database"]!["Host"]!.GetValue());
+ }
+ finally
+ {
+ Environment.SetEnvironmentVariable("MagicSettings__Database__Host", previous);
+ }
+ }
+
+ [Fact]
+ public async Task RemoteSnapshot_CannotOverrideControlPlaneBootstrapEndpoint()
+ {
+ using var directory = new TemporaryDirectory();
+ var path = Path.Combine(directory.Path, "settings.json");
+ var transport = new FakeTransport(
+ "MagicSettings:ControlPlane:Endpoint",
+ MagicRemoteValue.From("https://attacker.example/"));
+ var builder = new HostApplicationBuilder();
+ await builder.AddMagicSettingsAsync(configure: options =>
+ {
+ options.Path = path;
+ options.Template.MagicSettings.ControlPlane.Endpoint = "https://trusted-local.example/";
+ options.ControlPlaneTransport = transport;
+ });
+ await using var provider = builder.Services.BuildServiceProvider();
+ await provider.GetRequiredService().ConfigureAsync(
+ new Uri("https://control.example/"),
+ MagicControlPlaneTrust.SystemTls("Control.Api"));
+
+ var settings = provider.GetRequiredService>();
+ Assert.Equal("https://trusted-local.example/", settings.Current.MagicSettings.ControlPlane.Endpoint);
+ }
+
+ [Fact]
+ public async Task DevelopmentRequiresExplicitArrayPolicy()
+ {
+ using var directory = new TemporaryDirectory();
+ var path = Path.Combine(directory.Path, "settings.json");
+ var first = new HostApplicationBuilder(new HostApplicationBuilderSettings { EnvironmentName = "Development" });
+ await first.AddMagicSettingsAsync(configure: options =>
+ {
+ options.Path = path;
+ options.Environment = "Development";
+ options.Template.Origins = ["https://one.example"];
+ });
+
+ var second = new HostApplicationBuilder(new HostApplicationBuilderSettings { EnvironmentName = "Development" });
+ await Assert.ThrowsAsync(async () =>
+ await second.AddMagicSettingsAsync(configure: options =>
+ {
+ options.Path = path;
+ options.Environment = "Development";
+ options.Template.Origins = ["https://one.example", "https://two.example"];
+ }));
+ }
+
+ private sealed class FakeTransport : IMagicControlPlaneTransport
+ {
+ private readonly string _path;
+ private readonly MagicRemoteValue _value;
+ public FakeTransport(string path, MagicRemoteValue value)
+ {
+ _path = path;
+ _value = value;
+ }
+
+ public ValueTask SynchronizeAsync(Uri endpoint, MagicControlPlaneTrust trust, MagicSettingsSyncRequest request, CancellationToken cancellationToken = default)
+ => ValueTask.FromResult(new MagicSettingsSyncResponse(
+ MagicControlPlaneState.Active,
+ new MagicRemoteSnapshot(1, DateTimeOffset.UtcNow, new Dictionary
+ {
+ [_path] = _value
+ })));
+ }
+}
+}
+namespace MagicSettings.Tests
+{
+public sealed class MigrationTests
+{
+ [Fact]
+ public async Task SequentialMigrationRenamesAndTransformsWithoutDeletingRemoteHistory()
+ {
+ using var directory = new TemporaryDirectory();
+ var path = Path.Combine(directory.Path, "settings.json");
+ await File.WriteAllTextAsync(path, """
+ {
+ "Database": {
+ "Server": "old-host",
+ "Port": "7443"
+ },
+ "$magicSettings": {
+ "schemaVersion": 1
+ }
+ }
+ """);
+
+ var builder = new HostApplicationBuilder();
+ await builder.AddMagicSettingsAsync(configure: options =>
+ {
+ options.Path = path;
+ options.SchemaVersion = 2;
+ options.Migrations.Add(new VersionOneToTwo());
+ });
+
+ var document = JsonNode.Parse(await File.ReadAllTextAsync(path))!.AsObject();
+ Assert.Equal("old-host", document["Database"]!["Host"]!.GetValue());
+ Assert.Equal(7443, document["Database"]!["Port"]!.GetValue());
+ Assert.Equal(2, document["$magicSettings"]!["schemaVersion"]!.GetValue());
+ }
+
+ [Fact]
+ public async Task MissingMigrationFailsStartupInEveryEnvironment()
+ {
+ using var directory = new TemporaryDirectory();
+ var path = Path.Combine(directory.Path, "settings.json");
+ await File.WriteAllTextAsync(path, """{"$magicSettings":{"schemaVersion":1}}""");
+ var builder = new HostApplicationBuilder(new HostApplicationBuilderSettings { EnvironmentName = "Production" });
+
+ await Assert.ThrowsAsync(async () =>
+ await builder.AddMagicSettingsAsync(configure: options =>
+ {
+ options.Path = path;
+ options.SchemaVersion = 2;
+ }));
+ }
+
+ private sealed class VersionOneToTwo : IMagicSettingsMigration
+ {
+ public int FromVersion => 1;
+ public int ToVersion => 2;
+
+ public void Apply(JsonObject document, MagicMigrationContext context)
+ {
+ context.Rename(document, "Database:Server", "Database:Host");
+ context.Transform(
+ document,
+ "Database:Port",
+ node => JsonValue.Create(int.Parse(node!.GetValue(), System.Globalization.CultureInfo.InvariantCulture)),
+ "Convert port from string to integer.",
+ remoteSafeProjection: false);
+ }
+ }
+}
+}
+namespace MagicSettings.Tests
+{
+public sealed class ProofTests
+{
+ [Fact]
+ public async Task Proof_IsAudienceMethodTargetAndBodyBound_AndCannotReplay()
+ {
+ using var directory = new TemporaryDirectory();
+ var manager = new MagicNodeIdentityManager(new FileMagicNodeIdentityStore(Path.Combine(directory.Path, "identity.json")));
+ var identity = await manager.GetCurrentAsync();
+ var registry = new InMemoryMagicCredentialRegistry();
+ await registry.UpsertAsync(new(identity.NodeId, identity.CredentialId, identity.PublicKey, MagicCredentialStatus.Approved, DateTimeOffset.UtcNow));
+ var verifier = new MagicNodeProofVerifier(registry, new InMemoryMagicReplayCache());
+ var uri = new Uri("https://api.example/v1/items?x=1");
+ var bodyHash = MagicHash.Sha256Hex("hello"u8);
+ var proof = await manager.CreateProofAsync(new("Items.Api", "POST", uri, bodyHash));
+ var request = new MagicProofVerificationRequest(proof, "Items.Api", "POST", uri, bodyHash, DateTimeOffset.UtcNow);
+
+ Assert.True((await verifier.VerifyAsync(request)).IsValid);
+ Assert.Equal("The proof nonce has already been used.", (await verifier.VerifyAsync(request)).Error);
+ }
+
+ [Fact]
+ public async Task Proof_RejectsWrongAudienceBeforeNonceConsumption()
+ {
+ using var directory = new TemporaryDirectory();
+ var manager = new MagicNodeIdentityManager(new FileMagicNodeIdentityStore(Path.Combine(directory.Path, "identity.json")));
+ var identity = await manager.GetCurrentAsync();
+ var registry = new InMemoryMagicCredentialRegistry();
+ await registry.UpsertAsync(new(identity.NodeId, identity.CredentialId, identity.PublicKey, MagicCredentialStatus.Approved, DateTimeOffset.UtcNow));
+ var verifier = new MagicNodeProofVerifier(registry, new InMemoryMagicReplayCache());
+ var uri = new Uri("https://api.example/v1/items");
+ var proof = await manager.CreateProofAsync(new("Items.Api", "GET", uri, MagicHash.EmptySha256));
+
+ var wrong = await verifier.VerifyAsync(new(proof, "Admin.Api", "GET", uri, MagicHash.EmptySha256, DateTimeOffset.UtcNow));
+ var correct = await verifier.VerifyAsync(new(proof, "Items.Api", "GET", uri, MagicHash.EmptySha256, DateTimeOffset.UtcNow));
+
+ Assert.False(wrong.IsValid);
+ Assert.True(correct.IsValid);
+ }
+}
+}
+namespace MagicSettings.Tests
+{
+public sealed class SecretTests
+{
+ [Fact]
+ public async Task SecretRequestUsesFreshBoundNodeProof()
+ {
+ using var directory = new TemporaryDirectory();
+ var manager = new MagicNodeIdentityManager(new FileMagicNodeIdentityStore(Path.Combine(directory.Path, "identity.json")));
+ var identity = await manager.GetCurrentAsync();
+ var registry = new InMemoryMagicCredentialRegistry();
+ await registry.UpsertAsync(new(identity.NodeId, identity.CredentialId, identity.PublicKey, MagicCredentialStatus.Approved, DateTimeOffset.UtcNow));
+ var verifier = new MagicNodeProofVerifier(registry, new InMemoryMagicReplayCache());
+ var resolver = new FakeSecretResolver();
+ var service = new MagicSecretService(verifier, resolver);
+ var uri = new Uri("https://control.example/magicsettings/secrets/resolve");
+ const string name = "Database:Password";
+ var proof = await manager.CreateProofAsync(new("Control.Api", "POST", uri, MagicSecretProof.ComputeBodySha256(name)));
+
+ var response = await service.ResolveAsync(new(identity.NodeId, identity.CredentialId, name, proof), "Control.Api", uri);
+
+ Assert.True(response.Found);
+ Assert.Equal("secret-value", response.Value);
+ }
+
+ private sealed class FakeSecretResolver : IMagicSecretResolver
+ {
+ public ValueTask ResolveAsync(Guid nodeId, string name, CancellationToken cancellationToken = default)
+ => ValueTask.FromResult(new MagicSecretResponse(true, "secret-value"));
+ }
+}
+}
+namespace MagicSettings.Tests
+{
+public sealed class ServerSafetyTests
+{
+ [Fact]
+ public async Task SyncService_RetainsDestructiveMigrationAsManualReview()
+ {
+ using var directory = new TemporaryDirectory();
+ var manager = new MagicNodeIdentityManager(new FileMagicNodeIdentityStore(Path.Combine(directory.Path, "identity.json")));
+ var identity = await manager.GetCurrentAsync();
+ var registry = new InMemoryMagicCredentialRegistry();
+ await registry.UpsertAsync(new(identity.NodeId, identity.CredentialId, identity.PublicKey, MagicCredentialStatus.Approved, DateTimeOffset.UtcNow));
+ var records = new InMemoryMagicNodeRemoteRecordStore();
+ var verifier = new MagicNodeProofVerifier(registry, new InMemoryMagicReplayCache());
+ var service = new MagicSettingsSyncService(registry, records, verifier);
+ var uri = new Uri("https://control.example/magicsettings/sync");
+ var report = new MagicSettingsMigrationReport(1, 2, [],
+ [
+ new("Secrets:Old", "remove", MagicMigrationReviewSeverity.Destructive, "Client no longer uses this secret.")
+ ]);
+ var manifest = new MagicSettingsSchemaManifest("TestApp", "1.0.0", 2, "fingerprint", []);
+ var bodyHash = MagicSettingsSyncProof.ComputeBodySha256(identity, manifest, 0, report);
+ var proof = await manager.CreateProofAsync(new("Control.Api", "POST", uri, bodyHash));
+
+ var response = await service.SynchronizeAsync(new(identity, proof, manifest, 0, report), "Control.Api", uri);
+ var stored = await records.GetAsync(identity.NodeId, "TestApp");
+
+ Assert.Equal(MagicControlPlaneState.Active, response.State);
+ Assert.Single(stored!.PendingReviewItems);
+ Assert.Equal("Secrets:Old", stored.PendingReviewItems[0].Path);
+ }
+}
+}
+namespace MagicSettings.Tests
+{
+internal sealed class TemporaryDirectory : IDisposable
+{
+ public TemporaryDirectory()
+ {
+ Path = System.IO.Path.Combine(System.IO.Path.GetTempPath(), "MagicSettings.Tests", Guid.NewGuid().ToString("N"));
+ Directory.CreateDirectory(Path);
+ }
+
+ public string Path { get; }
+
+ public void Dispose()
+ {
+ try
+ {
+ Directory.Delete(Path, recursive: true);
+ }
+ catch (IOException)
+ {
+ }
+ catch (UnauthorizedAccessException)
+ {
+ }
+ }
+}
+}
+namespace MagicSettings.Tests
+{
+public sealed class TestSettings
+{
+ public TestApplication Application { get; set; } = new();
+ public TestDatabase Database { get; set; } = new();
+ public TestControlPlane MagicSettings { get; set; } = new();
+ public List Origins { get; set; } = new();
+}
+
+public sealed class TestApplication
+{
+ public string Name { get; set; } = "Test";
+ public bool FeatureEnabled { get; set; }
+}
+
+public sealed class TestDatabase
+{
+ public string Host { get; set; } = "localhost";
+ public string Password { get; set; } = "change-me";
+ public int Port { get; set; } = 5432;
+}
+
+public sealed class TestControlPlane
+{
+ public TestControlPlaneSection ControlPlane { get; set; } = new();
+}
+
+public sealed class TestControlPlaneSection
+{
+ public string? Endpoint { get; set; }
+}
+}
diff --git a/MagicSettings/MagicSettings.Tests/UnitTest1.cs b/MagicSettings/MagicSettings.Tests/UnitTest1.cs
deleted file mode 100644
index c4dc9ef..0000000
--- a/MagicSettings/MagicSettings.Tests/UnitTest1.cs
+++ /dev/null
@@ -1,15 +0,0 @@
-namespace MagicSettings.Tests;
-
-public class Tests
-{
- [SetUp]
- public void Setup()
- {
- }
-
- [Test]
- public void Test1()
- {
- Assert.Pass();
- }
-}
\ No newline at end of file
diff --git a/MagicSettings/MagicSettings.sln b/MagicSettings/MagicSettings.sln
index 0cb58da..28518a5 100644
--- a/MagicSettings/MagicSettings.sln
+++ b/MagicSettings/MagicSettings.sln
@@ -1,34 +1,36 @@
-
Microsoft Visual Studio Solution File, Format Version 12.00
+# Visual Studio Version 17
+VisualStudioVersion = 17.0.31903.59
+MinimumVisualStudioVersion = 10.0.40219.1
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MagicSettings", "MagicSettings\MagicSettings.csproj", "{C61F3392-5B49-46BB-8BC0-FEEAA830BC5E}"
EndProject
-Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MagicSettings.Tests", "MagicSettings.Tests\MagicSettings.Tests.csproj", "{66496E6F-9EFB-45C2-A981-88D1E315C705}"
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MagicSettings.Share", "MagicSettings.Share\MagicSettings.Share.csproj", "{C492C153-D111-4874-93B3-0E41A5FDEDB1}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MagicSettings.Server", "MagicSettings.Server\MagicSettings.Server.csproj", "{876477AA-F425-456B-9E7A-30CC10F67EF7}"
EndProject
-Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MagicSettings.Share", "MagicSettings.Share\MagicSettings.Share.csproj", "{C492C153-D111-4874-93B3-0E41A5FDEDB1}"
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MagicSettings.Tests", "MagicSettings.Tests\MagicSettings.Tests.csproj", "{66496E6F-9EFB-45C2-A981-88D1E315C705}"
EndProject
Global
- GlobalSection(SolutionConfigurationPlatforms) = preSolution
- Debug|Any CPU = Debug|Any CPU
- Release|Any CPU = Release|Any CPU
- EndGlobalSection
- GlobalSection(ProjectConfigurationPlatforms) = postSolution
- {C61F3392-5B49-46BB-8BC0-FEEAA830BC5E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
- {C61F3392-5B49-46BB-8BC0-FEEAA830BC5E}.Debug|Any CPU.Build.0 = Debug|Any CPU
- {C61F3392-5B49-46BB-8BC0-FEEAA830BC5E}.Release|Any CPU.ActiveCfg = Release|Any CPU
- {C61F3392-5B49-46BB-8BC0-FEEAA830BC5E}.Release|Any CPU.Build.0 = Release|Any CPU
- {66496E6F-9EFB-45C2-A981-88D1E315C705}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
- {66496E6F-9EFB-45C2-A981-88D1E315C705}.Debug|Any CPU.Build.0 = Debug|Any CPU
- {66496E6F-9EFB-45C2-A981-88D1E315C705}.Release|Any CPU.ActiveCfg = Release|Any CPU
- {66496E6F-9EFB-45C2-A981-88D1E315C705}.Release|Any CPU.Build.0 = Release|Any CPU
- {876477AA-F425-456B-9E7A-30CC10F67EF7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
- {876477AA-F425-456B-9E7A-30CC10F67EF7}.Debug|Any CPU.Build.0 = Debug|Any CPU
- {876477AA-F425-456B-9E7A-30CC10F67EF7}.Release|Any CPU.ActiveCfg = Release|Any CPU
- {876477AA-F425-456B-9E7A-30CC10F67EF7}.Release|Any CPU.Build.0 = Release|Any CPU
- {C492C153-D111-4874-93B3-0E41A5FDEDB1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
- {C492C153-D111-4874-93B3-0E41A5FDEDB1}.Debug|Any CPU.Build.0 = Debug|Any CPU
- {C492C153-D111-4874-93B3-0E41A5FDEDB1}.Release|Any CPU.ActiveCfg = Release|Any CPU
- {C492C153-D111-4874-93B3-0E41A5FDEDB1}.Release|Any CPU.Build.0 = Release|Any CPU
- EndGlobalSection
+ GlobalSection(SolutionConfigurationPlatforms) = preSolution
+ Debug|Any CPU = Debug|Any CPU
+ Release|Any CPU = Release|Any CPU
+ EndGlobalSection
+ GlobalSection(ProjectConfigurationPlatforms) = postSolution
+ {C61F3392-5B49-46BB-8BC0-FEEAA830BC5E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {C61F3392-5B49-46BB-8BC0-FEEAA830BC5E}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {C61F3392-5B49-46BB-8BC0-FEEAA830BC5E}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {C61F3392-5B49-46BB-8BC0-FEEAA830BC5E}.Release|Any CPU.Build.0 = Release|Any CPU
+ {C492C153-D111-4874-93B3-0E41A5FDEDB1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {C492C153-D111-4874-93B3-0E41A5FDEDB1}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {C492C153-D111-4874-93B3-0E41A5FDEDB1}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {C492C153-D111-4874-93B3-0E41A5FDEDB1}.Release|Any CPU.Build.0 = Release|Any CPU
+ {876477AA-F425-456B-9E7A-30CC10F67EF7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {876477AA-F425-456B-9E7A-30CC10F67EF7}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {876477AA-F425-456B-9E7A-30CC10F67EF7}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {876477AA-F425-456B-9E7A-30CC10F67EF7}.Release|Any CPU.Build.0 = Release|Any CPU
+ {66496E6F-9EFB-45C2-A981-88D1E315C705}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {66496E6F-9EFB-45C2-A981-88D1E315C705}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {66496E6F-9EFB-45C2-A981-88D1E315C705}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {66496E6F-9EFB-45C2-A981-88D1E315C705}.Release|Any CPU.Build.0 = Release|Any CPU
+ EndGlobalSection
EndGlobal
diff --git a/MagicSettings/MagicSettings/MagicSettings.csproj b/MagicSettings/MagicSettings/MagicSettings.csproj
index 237d661..336d442 100644
--- a/MagicSettings/MagicSettings/MagicSettings.csproj
+++ b/MagicSettings/MagicSettings/MagicSettings.csproj
@@ -1,9 +1,10 @@
-
-
-
- net10.0
- enable
- enable
-
-
+
+
+ MagicSettings
+ Strongly typed configuration with generation, reconciliation, migrations, live overrides, and client-owned control-plane integration.
+
+
+
+
+
diff --git a/MagicSettings/MagicSettings/Source.01.cs b/MagicSettings/MagicSettings/Source.01.cs
new file mode 100644
index 0000000..73223f4
--- /dev/null
+++ b/MagicSettings/MagicSettings/Source.01.cs
@@ -0,0 +1,163 @@
+// Consolidated source file. See repository history and wiki for subsystem boundaries.
+using System.Text.Json.Nodes;
+using MagicSettings.Share;
+using System.Reflection;
+using System.Security.Cryptography;
+using System.Text;
+using System.Text.Json;
+using Microsoft.Extensions.Hosting;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.DependencyInjection;
+using System.Net.Http.Headers;
+using System.Collections.Concurrent;
+using System.Net.Security;
+using System.Security.Cryptography.X509Certificates;
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.Options;
+using System.Collections;
+using System.Globalization;
+using System.ComponentModel.DataAnnotations;
+
+namespace MagicSettings
+{
+public interface IMagicSettings where TSettings : class
+{
+ TSettings Current { get; }
+ long Revision { get; }
+ event EventHandler>? Changed;
+ MagicSettingExplanation Explain(string path);
+}
+
+public sealed record MagicSettingsChangedEventArgs(TSettings Previous, TSettings Current, long Revision) where TSettings : class;
+
+public sealed record MagicSettingExplanation(
+ string Path,
+ string? EffectiveValue,
+ string EffectiveSource,
+ IReadOnlyList Sources,
+ bool IsSensitive);
+
+public sealed record MagicSettingSourceValue(string Source, bool Present, string? Value);
+
+public interface IMagicSettingsValidator where TSettings : class
+{
+ ValueTask> ValidateAsync(TSettings settings, CancellationToken cancellationToken = default);
+}
+
+public interface IMagicSettingsSourceProvider
+{
+ string Name { get; }
+ int Priority { get; }
+ ValueTask> LoadAsync(CancellationToken cancellationToken = default);
+}
+
+public interface IMagicSettingsMigration
+{
+ int FromVersion { get; }
+ int ToVersion { get; }
+ void Apply(JsonObject document, MagicMigrationContext context);
+}
+
+public interface IMagicSecretProvider
+{
+ ValueTask> GetAsync(string name, CancellationToken cancellationToken = default);
+}
+
+public sealed class MagicSecretLease : IAsyncDisposable
+{
+ public MagicSecretLease(T value, DateTimeOffset? expiresUtc = null)
+ {
+ Value = value;
+ ExpiresUtc = expiresUtc;
+ }
+
+ public T Value { get; }
+ public DateTimeOffset? ExpiresUtc { get; }
+ public ValueTask DisposeAsync() => ValueTask.CompletedTask;
+}
+
+public interface IMagicControlPlaneEndpointResolver
+{
+ MagicResolvedControlPlaneEndpoint Resolve(
+ MagicSettingsOptions options,
+ JsonObject persistentDocument,
+ Uri? runtimeOverride = null) where TSettings : class, new();
+}
+
+public interface IMagicSettingsControlPlane
+{
+ MagicControlPlaneState State { get; }
+ MagicResolvedControlPlaneEndpoint CurrentEndpoint { get; }
+ ValueTask ConfigureAsync(Uri endpoint, MagicControlPlaneTrust trust, CancellationToken cancellationToken = default);
+ ValueTask DisconnectAsync(bool clearRemoteOverrides = false, CancellationToken cancellationToken = default);
+ ValueTask RefreshAsync(CancellationToken cancellationToken = default);
+}
+
+public interface IMagicControlPlaneTransport
+{
+ ValueTask SynchronizeAsync(
+ Uri endpoint,
+ MagicControlPlaneTrust trust,
+ MagicSettingsSyncRequest request,
+ CancellationToken cancellationToken = default);
+}
+
+
+public interface IMagicSecretTransport
+{
+ ValueTask ResolveSecretAsync(
+ Uri endpoint,
+ MagicControlPlaneTrust trust,
+ MagicSecretRequest request,
+ CancellationToken cancellationToken = default);
+}
+
+public interface IMagicNodeAuthenticator
+{
+ ValueTask GetCurrentIdentityAsync(CancellationToken cancellationToken = default);
+ ValueTask CreateProofAsync(MagicAuthenticationRequest request, CancellationToken cancellationToken = default);
+}
+
+public interface IMagicNodeIdentityManager
+{
+ event EventHandler? IdentityChanged;
+ ValueTask GetCurrentAsync(CancellationToken cancellationToken = default);
+ ValueTask RotateAsync(string reason, CancellationToken cancellationToken = default);
+ ValueTask ResetAsync(MagicIdentityResetRequest request, CancellationToken cancellationToken = default);
+}
+
+public interface IMagicNodeIdentityStore
+{
+ ValueTask LoadAsync(CancellationToken cancellationToken = default);
+ ValueTask SaveAsync(MagicStoredNodeIdentity identity, CancellationToken cancellationToken = default);
+ ValueTask DeleteAsync(CancellationToken cancellationToken = default);
+}
+
+public sealed record MagicStoredNodeIdentity(
+ Guid NodeId,
+ Guid CredentialId,
+ string PublicKey,
+ string PrivateKey,
+ DateTimeOffset CreatedUtc);
+}
+namespace MagicSettings
+{
+[AttributeUsage(AttributeTargets.Property)]
+public sealed class MagicSensitiveAttribute : Attribute
+{
+}
+
+[AttributeUsage(AttributeTargets.Property)]
+public sealed class MagicRemoteOverrideAttribute : Attribute
+{
+ public MagicRemoteOverrideAttribute(bool allowed = true) => Allowed = allowed;
+ public bool Allowed { get; }
+}
+
+[AttributeUsage(AttributeTargets.Property)]
+public sealed class MagicSettingDescriptionAttribute : Attribute
+{
+ public MagicSettingDescriptionAttribute(string description) => Description = description;
+ public string Description { get; }
+}
+}
diff --git a/MagicSettings/MagicSettings/Source.02.cs b/MagicSettings/MagicSettings/Source.02.cs
new file mode 100644
index 0000000..a916a75
--- /dev/null
+++ b/MagicSettings/MagicSettings/Source.02.cs
@@ -0,0 +1,379 @@
+// Consolidated source file. See repository history and wiki for subsystem boundaries.
+using System.Text.Json.Nodes;
+using MagicSettings.Share;
+using System.Reflection;
+using System.Security.Cryptography;
+using System.Text;
+using System.Text.Json;
+using Microsoft.Extensions.Hosting;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.DependencyInjection;
+using System.Net.Http.Headers;
+using System.Collections.Concurrent;
+using System.Net.Security;
+using System.Security.Cryptography.X509Certificates;
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.Options;
+using System.Collections;
+using System.Globalization;
+using System.ComponentModel.DataAnnotations;
+
+namespace MagicSettings
+{
+public sealed class MagicControlPlaneEndpointResolver : IMagicControlPlaneEndpointResolver
+{
+ public MagicResolvedControlPlaneEndpoint Resolve(
+ MagicSettingsOptions options,
+ JsonObject persistentDocument,
+ Uri? runtimeOverride = null) where TSettings : class, new()
+ {
+ if (runtimeOverride is not null)
+ {
+ return new(Validate(runtimeOverride, "runtime override", options.ControlPlane.Bootstrap.AllowInsecureHttp), MagicControlPlaneEndpointSource.RuntimeOverride);
+ }
+
+ var environmentValue = Environment.GetEnvironmentVariable(options.ControlPlane.Bootstrap.EnvironmentVariableName);
+ if (!string.IsNullOrWhiteSpace(environmentValue))
+ {
+ return new(ParseAbsolute(environmentValue, options.ControlPlane.Bootstrap.EnvironmentVariableName, options.ControlPlane.Bootstrap.AllowInsecureHttp), MagicControlPlaneEndpointSource.EnvironmentVariable);
+ }
+
+ if (MagicJsonPath.TryGet(persistentDocument, options.ControlPlane.Bootstrap.PersistentSettingPath, out var node)
+ && node is JsonValue value
+ && value.TryGetValue(out var persistentValue)
+ && !string.IsNullOrWhiteSpace(persistentValue))
+ {
+ return new(ParseAbsolute(persistentValue, options.ControlPlane.Bootstrap.PersistentSettingPath, options.ControlPlane.Bootstrap.AllowInsecureHttp), MagicControlPlaneEndpointSource.PersistentSettings);
+ }
+
+ return options.ControlPlane.Bootstrap.CodeFallbackEndpoint is { } fallback
+ ? new(Validate(fallback, "code fallback", options.ControlPlane.Bootstrap.AllowInsecureHttp), MagicControlPlaneEndpointSource.CodeFallback)
+ : MagicResolvedControlPlaneEndpoint.None;
+ }
+
+ private static Uri ParseAbsolute(string value, string source, bool allowInsecureHttp)
+ {
+ if (!Uri.TryCreate(value, UriKind.Absolute, out var endpoint))
+ {
+ throw new InvalidOperationException($"Control-plane endpoint from '{source}' must be an absolute URI.");
+ }
+
+ return Validate(endpoint, source, allowInsecureHttp);
+ }
+
+ internal static Uri Validate(Uri endpoint, string source, bool allowInsecureHttp)
+ {
+ if (!string.Equals(endpoint.Scheme, Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase)
+ && !string.Equals(endpoint.Scheme, Uri.UriSchemeHttp, StringComparison.OrdinalIgnoreCase))
+ {
+ throw new InvalidOperationException($"Control-plane endpoint from '{source}' must use HTTP or HTTPS.");
+ }
+
+ if (string.Equals(endpoint.Scheme, Uri.UriSchemeHttp, StringComparison.OrdinalIgnoreCase)
+ && !allowInsecureHttp
+ && !endpoint.IsLoopback)
+ {
+ throw new InvalidOperationException($"Control-plane endpoint from '{source}' must use HTTPS unless insecure HTTP is explicitly enabled or the endpoint is loopback.");
+ }
+
+ return endpoint;
+ }
+}
+
+internal sealed class MagicSettingsControlPlane : IMagicSettingsControlPlane where TSettings : class, new()
+{
+ private readonly MagicSettingsOptions _options;
+ private readonly MagicSettingsRuntime _runtime;
+ private readonly IMagicNodeAuthenticator _authenticator;
+ private readonly IMagicNodeIdentityManager _identityManager;
+ private readonly IMagicControlPlaneTransport _transport;
+ private readonly IMagicControlPlaneEndpointResolver _resolver;
+ private readonly MagicSettingsSchemaManifest _manifest;
+ private readonly SemaphoreSlim _gate = new(1, 1);
+ private MagicControlPlaneTrust? _trust;
+ private Uri? _runtimeOverrideEndpoint;
+ private MagicSettingsMigrationReport? _pendingMigrationReport;
+ private MagicIdentityContinuityProof? _pendingContinuityProof;
+ private long _lastRevision;
+
+ public MagicSettingsControlPlane(
+ MagicSettingsOptions options,
+ MagicSettingsRuntime runtime,
+ IMagicNodeAuthenticator authenticator,
+ IMagicNodeIdentityManager identityManager,
+ IMagicControlPlaneTransport transport,
+ IMagicControlPlaneEndpointResolver resolver,
+ JsonObject persistentDocument,
+ MagicSettingsMigrationReport? migrationReport)
+ {
+ _options = options;
+ _runtime = runtime;
+ _authenticator = authenticator;
+ _identityManager = identityManager;
+ _transport = transport;
+ _resolver = resolver;
+ _manifest = MagicManifestBuilder.Build(options);
+ _pendingMigrationReport = migrationReport;
+ CurrentEndpoint = resolver.Resolve(options, persistentDocument);
+ State = CurrentEndpoint.Endpoint is null ? MagicControlPlaneState.Disabled : MagicControlPlaneState.Configured;
+ _identityManager.IdentityChanged += OnIdentityChanged;
+ }
+
+ public MagicControlPlaneState State { get; private set; }
+ public MagicResolvedControlPlaneEndpoint CurrentEndpoint { get; private set; }
+
+ internal bool TryGetActiveConnection(out Uri endpoint, out MagicControlPlaneTrust trust)
+ {
+ if (State == MagicControlPlaneState.Active && CurrentEndpoint.Endpoint is { } current && _trust is { } currentTrust)
+ {
+ endpoint = current;
+ trust = currentTrust;
+ return true;
+ }
+
+ endpoint = null!;
+ trust = null!;
+ return false;
+ }
+
+ public async ValueTask BootstrapAsync(CancellationToken cancellationToken)
+ {
+ if (!_options.ControlPlane.Bootstrap.ConnectOnStartup || CurrentEndpoint.Endpoint is null)
+ {
+ return;
+ }
+
+ var trust = _options.ControlPlane.Bootstrap.Trust
+ ?? throw new InvalidOperationException("ConnectOnStartup requires an explicit MagicControlPlaneTrust configuration.");
+ await ConfigureResolvedAsync(CurrentEndpoint, trust, cancellationToken);
+ }
+
+ public async ValueTask ConfigureAsync(Uri endpoint, MagicControlPlaneTrust trust, CancellationToken cancellationToken = default)
+ {
+ ArgumentNullException.ThrowIfNull(endpoint);
+ ArgumentNullException.ThrowIfNull(trust);
+ endpoint = MagicControlPlaneEndpointResolver.Validate(endpoint, "runtime override", _options.ControlPlane.Bootstrap.AllowInsecureHttp);
+ var resolved = new MagicResolvedControlPlaneEndpoint(endpoint, MagicControlPlaneEndpointSource.RuntimeOverride);
+ await ConfigureResolvedAsync(resolved, trust, cancellationToken);
+ _runtimeOverrideEndpoint = endpoint;
+ }
+
+ public async ValueTask DisconnectAsync(bool clearRemoteOverrides = false, CancellationToken cancellationToken = default)
+ {
+ await _gate.WaitAsync(cancellationToken);
+ try
+ {
+ State = MagicControlPlaneState.Disconnected;
+ _trust = null;
+ if (clearRemoteOverrides)
+ {
+ _lastRevision = 0;
+ _runtime.ClearRemote();
+ }
+ }
+ finally
+ {
+ _gate.Release();
+ }
+ }
+
+ public async ValueTask RefreshAsync(CancellationToken cancellationToken = default)
+ {
+ await _gate.WaitAsync(cancellationToken);
+ try
+ {
+ if (CurrentEndpoint.Endpoint is null || _trust is null)
+ {
+ return;
+ }
+
+ await SynchronizeUnderLockAsync(CurrentEndpoint, _trust, cancellationToken);
+ }
+ finally
+ {
+ _gate.Release();
+ }
+ }
+
+ public async ValueTask ReevaluatePersistentEndpointAsync(JsonObject persistentDocument, CancellationToken cancellationToken)
+ {
+ if (!_options.ControlPlane.Bootstrap.WatchPersistentEndpoint)
+ {
+ return;
+ }
+
+ var resolved = _resolver.Resolve(_options, persistentDocument, _runtimeOverrideEndpoint);
+ if (Equals(resolved.Endpoint, CurrentEndpoint.Endpoint))
+ {
+ return;
+ }
+
+ if (resolved.Endpoint is null)
+ {
+ await _gate.WaitAsync(cancellationToken);
+ try
+ {
+ CurrentEndpoint = MagicResolvedControlPlaneEndpoint.None;
+ _trust = null;
+ State = MagicControlPlaneState.Disabled;
+ if (!_options.ControlPlane.KeepLastKnownGoodDuringOutage)
+ {
+ _lastRevision = 0;
+ _runtime.ClearRemote();
+ }
+ }
+ finally
+ {
+ _gate.Release();
+ }
+ return;
+ }
+
+ var trust = _options.ControlPlane.Bootstrap.Trust;
+ if (trust is null)
+ {
+ CurrentEndpoint = resolved;
+ State = MagicControlPlaneState.Configured;
+ return;
+ }
+
+ await ConfigureResolvedAsync(resolved, trust, cancellationToken);
+ }
+
+ private async ValueTask ConfigureResolvedAsync(MagicResolvedControlPlaneEndpoint resolved, MagicControlPlaneTrust trust, CancellationToken cancellationToken)
+ {
+ await _gate.WaitAsync(cancellationToken);
+ try
+ {
+ var previousEndpoint = CurrentEndpoint;
+ var previousTrust = _trust;
+ State = MagicControlPlaneState.Connecting;
+ try
+ {
+ await SynchronizeUnderLockAsync(resolved, trust, cancellationToken);
+ CurrentEndpoint = resolved;
+ _trust = trust;
+ }
+ catch
+ {
+ CurrentEndpoint = previousEndpoint;
+ _trust = previousTrust;
+ State = MagicControlPlaneState.Faulted;
+ throw;
+ }
+ }
+ finally
+ {
+ _gate.Release();
+ }
+ }
+
+ private async ValueTask SynchronizeUnderLockAsync(MagicResolvedControlPlaneEndpoint resolved, MagicControlPlaneTrust trust, CancellationToken cancellationToken)
+ {
+ var endpoint = resolved.Endpoint ?? throw new InvalidOperationException("A control-plane endpoint is required.");
+ var syncUri = new Uri(endpoint, "magicsettings/sync");
+ var identity = await _authenticator.GetCurrentIdentityAsync(cancellationToken);
+ var signedPayloadHash = MagicSettingsSyncProof.ComputeBodySha256(
+ identity,
+ _manifest,
+ _lastRevision,
+ _pendingMigrationReport,
+ _pendingContinuityProof);
+ var proof = await _authenticator.CreateProofAsync(
+ new(trust.AuthorityId, "POST", syncUri, signedPayloadHash),
+ cancellationToken);
+ var response = await _transport.SynchronizeAsync(
+ endpoint,
+ trust,
+ new(identity, proof, _manifest, _lastRevision, _pendingMigrationReport, _pendingContinuityProof),
+ cancellationToken);
+
+ State = response.State;
+ if (response.State == MagicControlPlaneState.Active)
+ {
+ _runtime.ApplyRemoteSnapshot(response.Snapshot, DateTimeOffset.UtcNow);
+ _lastRevision = response.Snapshot.Revision;
+ _pendingMigrationReport = null;
+ _pendingContinuityProof = null;
+ }
+ }
+
+ private void OnIdentityChanged(object? sender, MagicIdentityChange change)
+ {
+ _lastRevision = 0;
+ _pendingContinuityProof = change.ContinuityProof;
+ if (change.Kind == MagicIdentityChangeKind.Reset || change.Kind == MagicIdentityChangeKind.RecoveredAfterLoss)
+ {
+ _runtime.ClearRemote();
+ }
+ State = MagicControlPlaneState.PendingApproval;
+ }
+}
+
+internal static class MagicManifestBuilder
+{
+ public static MagicSettingsSchemaManifest Build(MagicSettingsOptions options) where TSettings : class, new()
+ {
+ var discovered = new List();
+ Visit(typeof(TSettings), string.Empty, discovered, new HashSet());
+ var entries = discovered
+ .Select(item => item with
+ {
+ Sensitive = item.Sensitive || options.SensitivePaths.Contains(item.Path),
+ RemoteOverrideAllowed = item.RemoteOverrideAllowed && !string.Equals(
+ item.Path,
+ options.ControlPlane.Bootstrap.PersistentSettingPath,
+ StringComparison.OrdinalIgnoreCase)
+ })
+ .ToList();
+ var canonical = string.Join("\n", entries.OrderBy(static item => item.Path, StringComparer.Ordinal).Select(static item => $"{item.Path}|{item.Type}|{item.Nullable}|{item.Sensitive}|{item.RemoteOverrideAllowed}"));
+ var fingerprint = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(canonical))).ToLowerInvariant();
+ return new(options.ApplicationId, options.ApplicationVersion, options.SchemaVersion, fingerprint, entries);
+ }
+
+ private static void Visit(Type type, string path, ICollection entries, ISet stack)
+ {
+ if (!stack.Add(type))
+ {
+ return;
+ }
+
+ try
+ {
+ foreach (var property in type.GetProperties(BindingFlags.Instance | BindingFlags.Public).Where(static property => property.CanRead))
+ {
+ var propertyPath = string.IsNullOrEmpty(path) ? property.Name : $"{path}:{property.Name}";
+ var propertyType = Nullable.GetUnderlyingType(property.PropertyType) ?? property.PropertyType;
+ var sensitive = property.IsDefined(typeof(MagicSensitiveAttribute), inherit: true);
+ var remoteAllowed = property.GetCustomAttribute(inherit: true)?.Allowed ?? true;
+ var description = property.GetCustomAttribute(inherit: true)?.Description;
+ if (IsLeaf(propertyType))
+ {
+ entries.Add(new(
+ propertyPath,
+ propertyType.FullName ?? propertyType.Name,
+ Nullable.GetUnderlyingType(property.PropertyType) is not null || !property.PropertyType.IsValueType,
+ sensitive,
+ remoteAllowed,
+ description));
+ }
+ else if (!typeof(System.Collections.IEnumerable).IsAssignableFrom(propertyType) || propertyType == typeof(string))
+ {
+ Visit(propertyType, propertyPath, entries, stack);
+ }
+ else
+ {
+ entries.Add(new(propertyPath, propertyType.FullName ?? propertyType.Name, true, sensitive, remoteAllowed, description));
+ }
+ }
+ }
+ finally
+ {
+ stack.Remove(type);
+ }
+ }
+
+ private static bool IsLeaf(Type type)
+ => type.IsPrimitive || type.IsEnum || type == typeof(string) || type == typeof(decimal) || type == typeof(Guid) || type == typeof(Uri) || type == typeof(DateTime) || type == typeof(DateTimeOffset) || type == typeof(TimeSpan);
+}
+}
diff --git a/MagicSettings/MagicSettings/Source.03.cs b/MagicSettings/MagicSettings/Source.03.cs
new file mode 100644
index 0000000..4751f52
--- /dev/null
+++ b/MagicSettings/MagicSettings/Source.03.cs
@@ -0,0 +1,345 @@
+// Consolidated source file. See repository history and wiki for subsystem boundaries.
+using System.Text.Json.Nodes;
+using MagicSettings.Share;
+using System.Reflection;
+using System.Security.Cryptography;
+using System.Text;
+using System.Text.Json;
+using Microsoft.Extensions.Hosting;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.DependencyInjection;
+using System.Net.Http.Headers;
+using System.Collections.Concurrent;
+using System.Net.Security;
+using System.Security.Cryptography.X509Certificates;
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.Options;
+using System.Collections;
+using System.Globalization;
+using System.ComponentModel.DataAnnotations;
+
+namespace MagicSettings
+{
+internal sealed record MagicSettingsDocumentResult(JsonObject Document, MagicSettingsMigrationReport? MigrationReport, bool Changed);
+
+internal static class MagicSettingsDocumentStore
+{
+ private const string MetadataProperty = "$magicSettings";
+
+ public static async ValueTask LoadAndReconcileAsync(
+ string path,
+ MagicSettingsOptions options,
+ bool forceRegenerate,
+ CancellationToken cancellationToken) where TSettings : class, new()
+ {
+ Directory.CreateDirectory(Path.GetDirectoryName(path) ?? AppContext.BaseDirectory);
+ var template = JsonSerializer.SerializeToNode(options.Template, options.Json) as JsonObject
+ ?? throw new InvalidOperationException("The settings template must serialize as a JSON object.");
+
+ JsonObject document;
+ var existed = File.Exists(path);
+ if (!existed || forceRegenerate)
+ {
+ document = (JsonObject)template.DeepClone();
+ }
+ else
+ {
+ await using var stream = File.OpenRead(path);
+ document = await JsonNode.ParseAsync(stream, cancellationToken: cancellationToken) as JsonObject
+ ?? throw new InvalidOperationException($"Settings file '{path}' must contain a JSON object.");
+ }
+
+ var currentVersion = GetSchemaVersion(document);
+ MagicSettingsMigrationReport? report = null;
+ if (existed && !forceRegenerate && currentVersion < options.SchemaVersion)
+ {
+ report = MagicMigrationRunner.Run(document, currentVersion, options.SchemaVersion, options.Migrations);
+ }
+
+ var strict = options.Failures.StrictDevelopmentMode && IsDevelopment(options.Environment);
+ var changed = !existed || forceRegenerate;
+ changed |= ReconcileObject(document, template, string.Empty, options, strict);
+ changed |= WriteMetadata(document, options);
+
+ if (changed)
+ {
+ await WriteAtomicAsync(path, document, options.Json, cancellationToken);
+ }
+
+ return new(document, report, changed);
+ }
+
+ public static async ValueTask ReadAsync(string path, CancellationToken cancellationToken)
+ {
+ await using var stream = File.OpenRead(path);
+ return await JsonNode.ParseAsync(stream, cancellationToken: cancellationToken) as JsonObject
+ ?? throw new InvalidOperationException($"Settings file '{path}' must contain a JSON object.");
+ }
+
+ private static bool ReconcileObject(JsonObject existing, JsonObject template, string path, MagicSettingsOptions options, bool strict)
+ where TSettings : class, new()
+ {
+ var changed = false;
+ foreach (var pair in template)
+ {
+ var childPath = string.IsNullOrEmpty(path) ? pair.Key : $"{path}:{pair.Key}";
+ if (!MagicJsonPath.TryGetProperty(existing, pair.Key, out var actualPropertyName, out var existingValue))
+ {
+ existing[pair.Key] = pair.Value?.DeepClone();
+ changed = true;
+ continue;
+ }
+
+ if (existingValue is JsonObject existingObject && pair.Value is JsonObject templateObject)
+ {
+ changed |= ReconcileObject(existingObject, templateObject, childPath, options, strict);
+ continue;
+ }
+
+ if (existingValue is JsonArray existingArray && pair.Value is JsonArray templateArray && !JsonNode.DeepEquals(existingArray, templateArray))
+ {
+ if (!options.ArrayPolicies.TryGetValue(childPath, out var policy))
+ {
+ if (strict)
+ {
+ throw new InvalidOperationException($"Array reconciliation for '{childPath}' is ambiguous. Configure an explicit MagicArrayMergePolicy.");
+ }
+
+ policy = MagicArrayMergePolicy.PreserveExisting;
+ }
+
+ changed |= ApplyArrayPolicy(existing, actualPropertyName ?? pair.Key, existingArray, templateArray, policy);
+ }
+ }
+
+ return changed;
+ }
+
+ private static bool ApplyArrayPolicy(JsonObject parent, string propertyName, JsonArray existing, JsonArray template, MagicArrayMergePolicy policy)
+ {
+ switch (policy)
+ {
+ case MagicArrayMergePolicy.PreserveExisting:
+ return false;
+ case MagicArrayMergePolicy.ReplaceWithTemplate:
+ parent[propertyName] = template.DeepClone();
+ return true;
+ case MagicArrayMergePolicy.AppendMissing:
+ case MagicArrayMergePolicy.Union:
+ var changed = false;
+ foreach (var item in template)
+ {
+ if (existing.Any(candidate => JsonNode.DeepEquals(candidate, item)))
+ {
+ continue;
+ }
+
+ existing.Add(item?.DeepClone());
+ changed = true;
+ }
+
+ return changed;
+ default:
+ throw new ArgumentOutOfRangeException(nameof(policy), policy, null);
+ }
+ }
+
+ private static int GetSchemaVersion(JsonObject document)
+ => document[MetadataProperty]?["schemaVersion"]?.GetValue() ?? 1;
+
+ private static bool WriteMetadata(JsonObject document, MagicSettingsOptions options) where TSettings : class, new()
+ {
+ var metadata = document[MetadataProperty] as JsonObject ?? new JsonObject();
+ var before = metadata.ToJsonString();
+ metadata["schemaVersion"] = options.SchemaVersion;
+ metadata["applicationId"] = options.ApplicationId;
+ metadata["generatedBy"] = "MagicSettings";
+ document[MetadataProperty] = metadata;
+ return !string.Equals(before, metadata.ToJsonString(), StringComparison.Ordinal);
+ }
+
+ private static async ValueTask WriteAtomicAsync(string path, JsonObject document, JsonSerializerOptions options, CancellationToken cancellationToken)
+ {
+ var temporary = $"{path}.{Guid.NewGuid():N}.tmp";
+ await File.WriteAllTextAsync(temporary, document.ToJsonString(options), cancellationToken);
+ if (File.Exists(path))
+ {
+ var backup = $"{path}.bak";
+ File.Copy(path, backup, overwrite: true);
+ File.Move(temporary, path, overwrite: true);
+ }
+ else
+ {
+ File.Move(temporary, path);
+ }
+ }
+
+ private static bool IsDevelopment(string environment)
+ => environment.Equals("Development", StringComparison.OrdinalIgnoreCase)
+ || environment.Equals("Local", StringComparison.OrdinalIgnoreCase)
+ || environment.Equals("Test", StringComparison.OrdinalIgnoreCase);
+}
+}
+namespace MagicSettings
+{
+internal sealed class MagicSettingsHostedService : BackgroundService where TSettings : class, new()
+{
+ private readonly MagicSettingsOptions _options;
+ private readonly MagicSettingsRuntime _runtime;
+ private readonly MagicSettingsControlPlane _controlPlane;
+ private readonly ILogger> _logger;
+ private readonly string _settingsPath;
+ private DateTime _lastWriteUtc;
+ private DateTimeOffset _nextRemotePoll;
+
+ public MagicSettingsHostedService(
+ MagicSettingsOptions options,
+ MagicSettingsRuntime runtime,
+ MagicSettingsControlPlane controlPlane,
+ ILogger> logger,
+ string settingsPath)
+ {
+ _options = options;
+ _runtime = runtime;
+ _controlPlane = controlPlane;
+ _logger = logger;
+ _settingsPath = settingsPath;
+ _lastWriteUtc = File.Exists(settingsPath) ? File.GetLastWriteTimeUtc(settingsPath) : DateTime.MinValue;
+ _nextRemotePoll = DateTimeOffset.UtcNow.Add(options.ControlPlane.PollInterval);
+ }
+
+ protected override async Task ExecuteAsync(CancellationToken stoppingToken)
+ {
+ try
+ {
+ await _controlPlane.BootstrapAsync(stoppingToken);
+ }
+ catch (Exception exception)
+ {
+ _logger.LogError(exception, "MagicSettings could not establish the bootstrap control-plane connection. Local configuration remains active.");
+ }
+
+ using var timer = new PeriodicTimer(TimeSpan.FromMilliseconds(500));
+ while (await timer.WaitForNextTickAsync(stoppingToken))
+ {
+ _runtime.RefreshRemoteExpirations(DateTimeOffset.UtcNow);
+ if (_options.ReloadOnChange)
+ {
+ await CheckPersistentFileAsync(stoppingToken);
+ }
+
+ if (DateTimeOffset.UtcNow >= _nextRemotePoll)
+ {
+ await PollRemoteAsync(stoppingToken);
+ _nextRemotePoll = DateTimeOffset.UtcNow.Add(JitteredPollInterval());
+ }
+ }
+ }
+
+ private async ValueTask CheckPersistentFileAsync(CancellationToken cancellationToken)
+ {
+ if (!File.Exists(_settingsPath))
+ {
+ return;
+ }
+
+ var currentWrite = File.GetLastWriteTimeUtc(_settingsPath);
+ if (currentWrite <= _lastWriteUtc)
+ {
+ return;
+ }
+
+ await Task.Delay(_options.ReloadDebounce, cancellationToken);
+ try
+ {
+ await _runtime.ReloadPersistentAsync(cancellationToken);
+ var document = await MagicSettingsDocumentStore.ReadAsync(_settingsPath, cancellationToken);
+ await _controlPlane.ReevaluatePersistentEndpointAsync(document, cancellationToken);
+ _lastWriteUtc = currentWrite;
+ }
+ catch (Exception exception)
+ {
+ _logger.LogError(exception, "MagicSettings rejected an invalid runtime reload and retained the last known good snapshot.");
+ }
+ }
+
+ private async ValueTask PollRemoteAsync(CancellationToken cancellationToken)
+ {
+ try
+ {
+ await _controlPlane.RefreshAsync(cancellationToken);
+ }
+ catch (Exception exception)
+ {
+ _logger.LogWarning(exception, "MagicSettings could not refresh remote overrides. The last known good remote snapshot remains active.");
+ }
+ }
+
+ private TimeSpan JitteredPollInterval()
+ {
+ var jitter = _options.ControlPlane.PollJitter;
+ if (jitter <= TimeSpan.Zero)
+ {
+ return _options.ControlPlane.PollInterval;
+ }
+
+ var milliseconds = Random.Shared.NextDouble() * jitter.TotalMilliseconds * 2 - jitter.TotalMilliseconds;
+ return _options.ControlPlane.PollInterval + TimeSpan.FromMilliseconds(milliseconds);
+ }
+}
+}
+namespace MagicSettings
+{
+public sealed class MagicNodeAuthenticationHandler : DelegatingHandler
+{
+ private readonly IMagicNodeAuthenticator _authenticator;
+ private readonly string _audience;
+
+ public MagicNodeAuthenticationHandler(IMagicNodeAuthenticator authenticator, string audience)
+ {
+ _authenticator = authenticator;
+ _audience = audience;
+ }
+
+ protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
+ {
+ ArgumentNullException.ThrowIfNull(request.RequestUri);
+ var body = request.Content is null
+ ? Array.Empty()
+ : await request.Content.ReadAsByteArrayAsync(cancellationToken);
+
+ if (request.Content is not null)
+ {
+ var replacement = new ByteArrayContent(body);
+ foreach (var header in request.Content.Headers)
+ {
+ replacement.Headers.TryAddWithoutValidation(header.Key, header.Value);
+ }
+ request.Content = replacement;
+ }
+
+ var proof = await _authenticator.CreateProofAsync(
+ new(
+ _audience,
+ request.Method.Method,
+ request.RequestUri,
+ MagicHash.Sha256Hex(body)),
+ cancellationToken);
+
+ request.Headers.Authorization = new AuthenticationHeaderValue("MagicNode", MagicNodeProofCodec.Encode(proof));
+ request.Headers.TryAddWithoutValidation("X-Magic-Node-Id", proof.NodeId.ToString("D"));
+ request.Headers.TryAddWithoutValidation("X-Magic-Credential-Id", proof.CredentialId.ToString("D"));
+ return await base.SendAsync(request, cancellationToken);
+ }
+}
+
+public static class MagicHttpClientBuilderExtensions
+{
+ public static IHttpClientBuilder AddMagicNodeAuthentication(this IHttpClientBuilder builder, string audience)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(audience);
+ return builder.AddHttpMessageHandler(serviceProvider =>
+ new MagicNodeAuthenticationHandler(serviceProvider.GetRequiredService(), audience));
+ }
+}
+}
diff --git a/MagicSettings/MagicSettings/Source.04.cs b/MagicSettings/MagicSettings/Source.04.cs
new file mode 100644
index 0000000..897ebf7
--- /dev/null
+++ b/MagicSettings/MagicSettings/Source.04.cs
@@ -0,0 +1,423 @@
+// Consolidated source file. See repository history and wiki for subsystem boundaries.
+using System.Text.Json.Nodes;
+using MagicSettings.Share;
+using System.Reflection;
+using System.Security.Cryptography;
+using System.Text;
+using System.Text.Json;
+using Microsoft.Extensions.Hosting;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.DependencyInjection;
+using System.Net.Http.Headers;
+using System.Collections.Concurrent;
+using System.Net.Security;
+using System.Security.Cryptography.X509Certificates;
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.Options;
+using System.Collections;
+using System.Globalization;
+using System.ComponentModel.DataAnnotations;
+
+namespace MagicSettings
+{
+public sealed class HttpMagicControlPlaneTransport : IMagicControlPlaneTransport, IMagicSecretTransport, IDisposable
+{
+ private readonly HttpClient _systemTlsClient;
+ private readonly ConcurrentDictionary _pinnedClients = new(StringComparer.OrdinalIgnoreCase);
+ private readonly JsonSerializerOptions _json;
+
+ public HttpMagicControlPlaneTransport(TimeSpan? timeout = null, JsonSerializerOptions? json = null)
+ {
+ _systemTlsClient = CreateClient(certificateValidator: null, timeout);
+ _json = json ?? new JsonSerializerOptions(JsonSerializerDefaults.Web);
+ }
+
+ public async ValueTask SynchronizeAsync(
+ Uri endpoint,
+ MagicControlPlaneTrust trust,
+ MagicSettingsSyncRequest request,
+ CancellationToken cancellationToken = default)
+ {
+ ArgumentNullException.ThrowIfNull(endpoint);
+ ArgumentNullException.ThrowIfNull(trust);
+ ArgumentNullException.ThrowIfNull(request);
+
+ var client = SelectClient(trust);
+
+ var syncUri = new Uri(endpoint, "magicsettings/sync");
+ using var message = new HttpRequestMessage(HttpMethod.Post, syncUri)
+ {
+ Content = new StringContent(JsonSerializer.Serialize(request, _json), Encoding.UTF8, "application/json")
+ };
+ message.Headers.Authorization = new AuthenticationHeaderValue("MagicNode", MagicNodeProofCodec.Encode(request.Proof));
+ using var response = await client.SendAsync(message, HttpCompletionOption.ResponseHeadersRead, cancellationToken);
+ response.EnsureSuccessStatusCode();
+ await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken);
+ return await JsonSerializer.DeserializeAsync(stream, _json, cancellationToken)
+ ?? throw new InvalidOperationException("The control plane returned an empty synchronization response.");
+ }
+
+
+ public async ValueTask ResolveSecretAsync(
+ Uri endpoint,
+ MagicControlPlaneTrust trust,
+ MagicSecretRequest request,
+ CancellationToken cancellationToken = default)
+ {
+ ArgumentNullException.ThrowIfNull(endpoint);
+ ArgumentNullException.ThrowIfNull(trust);
+ ArgumentNullException.ThrowIfNull(request);
+
+ var client = SelectClient(trust);
+ var requestUri = new Uri(endpoint, "magicsettings/secrets/resolve");
+ using var message = new HttpRequestMessage(HttpMethod.Post, requestUri)
+ {
+ Content = new StringContent(JsonSerializer.Serialize(request, _json), Encoding.UTF8, "application/json")
+ };
+ message.Headers.Authorization = new AuthenticationHeaderValue("MagicNode", MagicNodeProofCodec.Encode(request.Proof));
+ using var response = await client.SendAsync(message, HttpCompletionOption.ResponseHeadersRead, cancellationToken);
+ response.EnsureSuccessStatusCode();
+ await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken);
+ return await JsonSerializer.DeserializeAsync(stream, _json, cancellationToken)
+ ?? throw new InvalidOperationException("The control plane returned an empty secret response.");
+ }
+
+ public void Dispose()
+ {
+ _systemTlsClient.Dispose();
+ foreach (var client in _pinnedClients.Values)
+ {
+ client.Dispose();
+ }
+ }
+
+ private HttpClient SelectClient(MagicControlPlaneTrust trust)
+ => trust.Mode switch
+ {
+ MagicControlPlaneTrustMode.SystemTls => _systemTlsClient,
+ MagicControlPlaneTrustMode.PinnedPublicKey => GetPinnedClient(trust),
+ _ => throw new ArgumentOutOfRangeException(nameof(trust), trust.Mode, "Unsupported control-plane trust mode.")
+ };
+
+ private HttpClient GetPinnedClient(MagicControlPlaneTrust trust)
+ {
+ var expected = NormalizeFingerprint(trust.PinnedPublicKeyFingerprint
+ ?? throw new InvalidOperationException("PinnedPublicKey trust requires a public-key fingerprint."));
+ return _pinnedClients.GetOrAdd(expected, fingerprint => CreateClient(
+ (_, certificate, _, _) => certificate is not null && FixedTimeEquals(PublicKeyFingerprint(certificate), fingerprint),
+ _systemTlsClient.Timeout));
+ }
+
+ private static HttpClient CreateClient(
+ Func? certificateValidator,
+ TimeSpan? timeout)
+ {
+ var handler = new HttpClientHandler
+ {
+ AllowAutoRedirect = false
+ };
+ if (certificateValidator is not null)
+ {
+ handler.ServerCertificateCustomValidationCallback = certificateValidator;
+ }
+
+ return new HttpClient(handler, disposeHandler: true)
+ {
+ Timeout = timeout ?? TimeSpan.FromSeconds(30)
+ };
+ }
+
+ private static string PublicKeyFingerprint(X509Certificate2 certificate)
+ => Convert.ToHexString(SHA256.HashData(certificate.GetPublicKey())).ToLowerInvariant();
+
+ private static string NormalizeFingerprint(string value)
+ => value.Replace(":", string.Empty, StringComparison.Ordinal).Trim().ToLowerInvariant();
+
+ private static bool FixedTimeEquals(string actual, string expected)
+ {
+ var actualBytes = Encoding.ASCII.GetBytes(actual);
+ var expectedBytes = Encoding.ASCII.GetBytes(expected);
+ return actualBytes.Length == expectedBytes.Length
+ && CryptographicOperations.FixedTimeEquals(actualBytes, expectedBytes);
+ }
+}
+}
+namespace MagicSettings
+{
+public sealed class FileMagicNodeIdentityStore : IMagicNodeIdentityStore
+{
+ private readonly string _path;
+ private readonly JsonSerializerOptions _json = new(JsonSerializerDefaults.Web) { WriteIndented = true };
+
+ public FileMagicNodeIdentityStore(string path) => _path = Path.GetFullPath(path);
+
+ public async ValueTask LoadAsync(CancellationToken cancellationToken = default)
+ {
+ if (!File.Exists(_path))
+ {
+ return null;
+ }
+
+ await using var stream = File.OpenRead(_path);
+ return await JsonSerializer.DeserializeAsync(stream, _json, cancellationToken);
+ }
+
+ public async ValueTask SaveAsync(MagicStoredNodeIdentity identity, CancellationToken cancellationToken = default)
+ {
+ Directory.CreateDirectory(Path.GetDirectoryName(_path) ?? AppContext.BaseDirectory);
+ var temporary = $"{_path}.{Guid.NewGuid():N}.tmp";
+ await using (var stream = File.Create(temporary))
+ {
+ await JsonSerializer.SerializeAsync(stream, identity, _json, cancellationToken);
+ await stream.FlushAsync(cancellationToken);
+ }
+
+ TryRestrictPermissions(temporary);
+ File.Move(temporary, _path, overwrite: true);
+ TryRestrictPermissions(_path);
+ }
+
+ public ValueTask DeleteAsync(CancellationToken cancellationToken = default)
+ {
+ if (File.Exists(_path))
+ {
+ File.Delete(_path);
+ }
+
+ return ValueTask.CompletedTask;
+ }
+
+ private static void TryRestrictPermissions(string path)
+ {
+ if (!OperatingSystem.IsWindows())
+ {
+ File.SetUnixFileMode(path, UnixFileMode.UserRead | UnixFileMode.UserWrite);
+ }
+ }
+}
+
+internal static class MagicIdentityCryptography
+{
+ public const string ProofVersion = "MAGICSETTINGS-PROOF-V1";
+ public const string SignatureAlgorithm = "ECDSA_P256_SHA256_P1363";
+
+ public static MagicStoredNodeIdentity Create(Guid? nodeId = null)
+ {
+ using var key = ECDsa.Create(ECCurve.NamedCurves.nistP256);
+ return new(
+ nodeId ?? Guid.NewGuid(),
+ Guid.NewGuid(),
+ Convert.ToBase64String(key.ExportSubjectPublicKeyInfo()),
+ Convert.ToBase64String(key.ExportPkcs8PrivateKey()),
+ DateTimeOffset.UtcNow);
+ }
+
+ public static MagicNodeIdentityDescriptor Describe(MagicStoredNodeIdentity identity)
+ => new(
+ identity.NodeId,
+ identity.CredentialId,
+ MagicCredentialKind.EcdsaP256,
+ SignatureAlgorithm,
+ identity.PublicKey,
+ Fingerprint(identity.PublicKey),
+ identity.CreatedUtc);
+
+ public static string Sign(MagicStoredNodeIdentity identity, string canonical)
+ {
+ using var key = ECDsa.Create();
+ key.ImportPkcs8PrivateKey(Convert.FromBase64String(identity.PrivateKey), out _);
+ var signature = key.SignData(
+ Encoding.UTF8.GetBytes(canonical),
+ HashAlgorithmName.SHA256,
+ DSASignatureFormat.IeeeP1363FixedFieldConcatenation);
+ return Convert.ToBase64String(signature);
+ }
+
+ public static bool Verify(string publicKey, string canonical, string signature)
+ {
+ using var key = ECDsa.Create();
+ key.ImportSubjectPublicKeyInfo(Convert.FromBase64String(publicKey), out _);
+ return key.VerifyData(
+ Encoding.UTF8.GetBytes(canonical),
+ Convert.FromBase64String(signature),
+ HashAlgorithmName.SHA256,
+ DSASignatureFormat.IeeeP1363FixedFieldConcatenation);
+ }
+
+ public static string Canonicalize(MagicAuthenticationProof proof)
+ => string.Join('\n',
+ proof.Version,
+ proof.NodeId.ToString("D"),
+ proof.CredentialId.ToString("D"),
+ proof.Audience,
+ proof.Method.ToUpperInvariant(),
+ proof.Target,
+ proof.BodySha256.ToLowerInvariant(),
+ proof.IssuedUtc.ToUnixTimeMilliseconds().ToString(System.Globalization.CultureInfo.InvariantCulture),
+ proof.ExpiresUtc.ToUnixTimeMilliseconds().ToString(System.Globalization.CultureInfo.InvariantCulture),
+ proof.Nonce);
+
+ public static string CanonicalizeContinuity(MagicNodeIdentityDescriptor previous, MagicNodeIdentityDescriptor current, DateTimeOffset issuedUtc, string nonce)
+ => string.Join('\n',
+ "MAGICSETTINGS-IDENTITY-CONTINUITY-V1",
+ previous.NodeId.ToString("D"),
+ previous.CredentialId.ToString("D"),
+ current.CredentialId.ToString("D"),
+ current.PublicKey,
+ issuedUtc.ToUnixTimeMilliseconds().ToString(System.Globalization.CultureInfo.InvariantCulture),
+ nonce);
+
+ public static string NormalizeTarget(Uri uri)
+ => uri.IsAbsoluteUri
+ ? uri.GetComponents(UriComponents.SchemeAndServer | UriComponents.PathAndQuery, UriFormat.UriEscaped)
+ : uri.OriginalString;
+
+ public static string Fingerprint(string publicKey)
+ => Convert.ToHexString(SHA256.HashData(Convert.FromBase64String(publicKey))).ToLowerInvariant();
+}
+
+public sealed class MagicNodeIdentityManager : IMagicNodeIdentityManager, IMagicNodeAuthenticator
+{
+ private readonly IMagicNodeIdentityStore _store;
+ private readonly SemaphoreSlim _gate = new(1, 1);
+ private MagicStoredNodeIdentity? _current;
+
+ public MagicNodeIdentityManager(IMagicNodeIdentityStore store) => _store = store;
+
+ public event EventHandler? IdentityChanged;
+
+ public async ValueTask GetCurrentAsync(CancellationToken cancellationToken = default)
+ => MagicIdentityCryptography.Describe(await GetStoredAsync(cancellationToken));
+
+ public ValueTask GetCurrentIdentityAsync(CancellationToken cancellationToken = default)
+ => GetCurrentAsync(cancellationToken);
+
+ public async ValueTask CreateProofAsync(MagicAuthenticationRequest request, CancellationToken cancellationToken = default)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(request.Audience);
+ ArgumentException.ThrowIfNullOrWhiteSpace(request.Method);
+ ArgumentNullException.ThrowIfNull(request.Uri);
+ ArgumentException.ThrowIfNullOrWhiteSpace(request.BodySha256);
+
+ var identity = await GetStoredAsync(cancellationToken);
+ var issued = DateTimeOffset.UtcNow;
+ var validFor = request.ValidFor ?? TimeSpan.FromMinutes(1);
+ if (validFor <= TimeSpan.Zero || validFor > TimeSpan.FromMinutes(5))
+ {
+ throw new ArgumentOutOfRangeException(nameof(request), "Authentication proofs must be valid for more than zero and no longer than five minutes.");
+ }
+
+ var unsigned = new MagicAuthenticationProof(
+ MagicIdentityCryptography.ProofVersion,
+ identity.NodeId,
+ identity.CredentialId,
+ request.Audience,
+ request.Method.ToUpperInvariant(),
+ MagicIdentityCryptography.NormalizeTarget(request.Uri),
+ request.BodySha256.ToLowerInvariant(),
+ issued,
+ issued.Add(validFor),
+ MagicNodeProofCodec.Base64UrlEncode(RandomNumberGenerator.GetBytes(24)),
+ string.Empty);
+
+ return unsigned with { Signature = MagicIdentityCryptography.Sign(identity, MagicIdentityCryptography.Canonicalize(unsigned)) };
+ }
+
+ public async ValueTask RotateAsync(string reason, CancellationToken cancellationToken = default)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(reason);
+ await _gate.WaitAsync(cancellationToken);
+ try
+ {
+ var previous = await LoadOrCreateUnderLockAsync(cancellationToken);
+ var next = MagicIdentityCryptography.Create(previous.NodeId);
+ var previousDescriptor = MagicIdentityCryptography.Describe(previous);
+ var nextDescriptor = MagicIdentityCryptography.Describe(next);
+ var issued = DateTimeOffset.UtcNow;
+ var nonce = MagicNodeProofCodec.Base64UrlEncode(RandomNumberGenerator.GetBytes(24));
+ var canonical = MagicIdentityCryptography.CanonicalizeContinuity(previousDescriptor, nextDescriptor, issued, nonce);
+ var continuity = new MagicIdentityContinuityProof(previousDescriptor, nextDescriptor, issued, nonce, MagicIdentityCryptography.Sign(previous, canonical));
+ await _store.SaveAsync(next, cancellationToken);
+ _current = next;
+ var change = new MagicIdentityChange(MagicIdentityChangeKind.Rotated, nextDescriptor, previousDescriptor, continuity, reason);
+ IdentityChanged?.Invoke(this, change);
+ return change;
+ }
+ finally
+ {
+ _gate.Release();
+ }
+ }
+
+ public async ValueTask ResetAsync(MagicIdentityResetRequest request, CancellationToken cancellationToken = default)
+ {
+ ArgumentNullException.ThrowIfNull(request);
+ if (!request.ConfirmDestructiveReset)
+ {
+ throw new InvalidOperationException("Identity reset is destructive and requires ConfirmDestructiveReset=true.");
+ }
+
+ await _gate.WaitAsync(cancellationToken);
+ try
+ {
+ var previous = await _store.LoadAsync(cancellationToken);
+ var next = MagicIdentityCryptography.Create();
+ await _store.SaveAsync(next, cancellationToken);
+ _current = next;
+ var change = new MagicIdentityChange(
+ MagicIdentityChangeKind.Reset,
+ MagicIdentityCryptography.Describe(next),
+ previous is null ? null : MagicIdentityCryptography.Describe(previous),
+ null,
+ request.Reason);
+ IdentityChanged?.Invoke(this, change);
+ return change;
+ }
+ finally
+ {
+ _gate.Release();
+ }
+ }
+
+ private async ValueTask GetStoredAsync(CancellationToken cancellationToken)
+ {
+ if (_current is not null)
+ {
+ return _current;
+ }
+
+ await _gate.WaitAsync(cancellationToken);
+ try
+ {
+ return await LoadOrCreateUnderLockAsync(cancellationToken);
+ }
+ finally
+ {
+ _gate.Release();
+ }
+ }
+
+ private async ValueTask LoadOrCreateUnderLockAsync(CancellationToken cancellationToken)
+ {
+ if (_current is not null)
+ {
+ return _current;
+ }
+
+ _current = await _store.LoadAsync(cancellationToken);
+ if (_current is null)
+ {
+ _current = MagicIdentityCryptography.Create();
+ await _store.SaveAsync(_current, cancellationToken);
+ }
+
+ return _current;
+ }
+}
+
+public static class MagicHash
+{
+ public static string Sha256Hex(ReadOnlySpan value) => Convert.ToHexString(SHA256.HashData(value)).ToLowerInvariant();
+ public static string EmptySha256 { get; } = Sha256Hex(Array.Empty());
+}
+}
diff --git a/MagicSettings/MagicSettings/Source.05.cs b/MagicSettings/MagicSettings/Source.05.cs
new file mode 100644
index 0000000..de23aef
--- /dev/null
+++ b/MagicSettings/MagicSettings/Source.05.cs
@@ -0,0 +1,414 @@
+// Consolidated source file. See repository history and wiki for subsystem boundaries.
+using System.Text.Json.Nodes;
+using MagicSettings.Share;
+using System.Reflection;
+using System.Security.Cryptography;
+using System.Text;
+using System.Text.Json;
+using Microsoft.Extensions.Hosting;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.DependencyInjection;
+using System.Net.Http.Headers;
+using System.Collections.Concurrent;
+using System.Net.Security;
+using System.Security.Cryptography.X509Certificates;
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.Options;
+using System.Collections;
+using System.Globalization;
+using System.ComponentModel.DataAnnotations;
+
+namespace MagicSettings
+{
+public static class MagicSettingsInitializationExtensions
+{
+ public static async ValueTask AddMagicSettingsAsync(
+ this IHostApplicationBuilder builder,
+ string[]? args = null,
+ Action>? configure = null,
+ CancellationToken cancellationToken = default)
+ where TSettings : class, new()
+ {
+ ArgumentNullException.ThrowIfNull(builder);
+ var options = new MagicSettingsOptions();
+ configure?.Invoke(options);
+ options.Environment = MagicSettingsEnvironmentResolver.Resolve(options.Environment, builder.Environment.EnvironmentName);
+ if (options.EnvironmentProfiles.TryGetValue(options.Environment, out var environmentProfile))
+ {
+ environmentProfile(options.Template);
+ }
+
+ var command = MagicSettingsCommandLine.Parse(args ?? Array.Empty());
+ var path = MagicSettingsPathResolver.Resolve(options);
+ var documentResult = await MagicSettingsDocumentStore.LoadAndReconcileAsync(
+ path,
+ options,
+ command.ForceGenerate,
+ cancellationToken);
+
+ if (command.PrintPath)
+ {
+ Console.Out.WriteLine(path);
+ }
+
+ var identityPath = MagicIdentityPathResolver.Resolve(path, options.IdentityPath, options.IdentityFileName);
+ var identityStore = options.IdentityStore ?? new FileMagicNodeIdentityStore(identityPath);
+ var identityManager = new MagicNodeIdentityManager(identityStore);
+ await identityManager.GetCurrentAsync(cancellationToken);
+
+ var configurationProvider = new MagicSettingsConfigurationProvider();
+ var runtime = new MagicSettingsRuntime(options, configurationProvider, path);
+ await runtime.InitializeAsync(documentResult.Document, cancellationToken);
+
+ var endpointResolver = options.ControlPlaneEndpointResolver ?? new MagicControlPlaneEndpointResolver();
+ var transport = options.ControlPlaneTransport ?? new HttpMagicControlPlaneTransport();
+ var controlPlane = new MagicSettingsControlPlane(
+ options,
+ runtime,
+ identityManager,
+ identityManager,
+ transport,
+ endpointResolver,
+ documentResult.Document,
+ documentResult.MigrationReport);
+
+ builder.Configuration.Add(new MagicSettingsConfigurationSource(configurationProvider));
+ builder.Services.AddSingleton(options);
+ builder.Services.AddSingleton>(runtime);
+ builder.Services.AddSingleton(runtime);
+ builder.Services.AddSingleton(identityManager);
+ builder.Services.AddSingleton(identityManager);
+ builder.Services.AddSingleton(controlPlane);
+ builder.Services.AddSingleton(controlPlane);
+ builder.Services.AddSingleton(endpointResolver);
+ builder.Services.AddSingleton(transport);
+ if (transport is IMagicSecretTransport secretTransport)
+ {
+ builder.Services.AddSingleton(secretTransport);
+ builder.Services.AddSingleton(
+ new MagicControlPlaneSecretProvider(controlPlane, identityManager, secretTransport, options));
+ }
+ builder.Services.AddSingleton(new MagicSettingsRuntimeRegistration(path));
+ builder.Services.AddHostedService(serviceProvider =>
+ new MagicSettingsHostedService(
+ options,
+ runtime,
+ controlPlane,
+ serviceProvider.GetRequiredService>>(),
+ path));
+ builder.Services.AddOptions().Bind(builder.Configuration);
+
+ var shouldExit = command.GenerateOnly || command.ValidateOnly || command.PrintPath;
+ return new(shouldExit, 0, path, options.Environment);
+ }
+}
+
+public sealed record MagicSettingsRuntimeRegistration(string SettingsPath);
+
+internal sealed record MagicSettingsCommandLine(bool GenerateOnly, bool ForceGenerate, bool ValidateOnly, bool PrintPath)
+{
+ public static MagicSettingsCommandLine Parse(IEnumerable args)
+ {
+ var set = args.ToHashSet(StringComparer.OrdinalIgnoreCase);
+ var force = set.Contains("--magic-settings-force-generate");
+ return new(
+ set.Contains("--magic-settings-generate") || force,
+ force,
+ set.Contains("--magic-settings-validate"),
+ set.Contains("--magic-settings-print-path"));
+ }
+}
+}
+namespace MagicSettings
+{
+internal static class MagicJsonPath
+{
+ public static bool TryGet(JsonObject root, string path, out JsonNode? value)
+ {
+ value = root;
+ foreach (var segment in Split(path))
+ {
+ if (value is not JsonObject obj || !TryGetProperty(obj, segment, out _, out value))
+ {
+ value = null;
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+ public static void Set(JsonObject root, string path, JsonNode? value)
+ {
+ var segments = Split(path);
+ if (segments.Length == 0)
+ {
+ throw new ArgumentException("A setting path is required.", nameof(path));
+ }
+
+ var current = root;
+ for (var index = 0; index < segments.Length - 1; index++)
+ {
+ var segment = segments[index];
+ if (!TryGetProperty(current, segment, out var actualName, out var existing) || existing is not JsonObject child)
+ {
+ child = new JsonObject();
+ current[actualName ?? segment] = child;
+ }
+
+ current = child;
+ }
+
+ if (TryGetProperty(current, segments[^1], out var finalName, out _))
+ {
+ current[finalName!] = value?.DeepClone();
+ }
+ else
+ {
+ current[segments[^1]] = value?.DeepClone();
+ }
+ }
+
+ public static bool TryTake(JsonObject root, string path, out JsonNode? value)
+ {
+ value = null;
+ var segments = Split(path);
+ if (segments.Length == 0)
+ {
+ return false;
+ }
+
+ var current = root;
+ for (var index = 0; index < segments.Length - 1; index++)
+ {
+ if (!TryGetProperty(current, segments[index], out _, out var existing) || existing is not JsonObject child)
+ {
+ return false;
+ }
+
+ current = child;
+ }
+
+ if (!TryGetProperty(current, segments[^1], out var actualName, out value))
+ {
+ return false;
+ }
+
+ current.Remove(actualName!);
+ return true;
+ }
+
+ public static bool TryGetProperty(JsonObject obj, string name, out string? actualName, out JsonNode? value)
+ {
+ if (obj.TryGetPropertyValue(name, out value))
+ {
+ actualName = name;
+ return true;
+ }
+
+ foreach (var pair in obj)
+ {
+ if (string.Equals(pair.Key, name, StringComparison.OrdinalIgnoreCase))
+ {
+ actualName = pair.Key;
+ value = pair.Value;
+ return true;
+ }
+ }
+
+ actualName = null;
+ value = null;
+ return false;
+ }
+
+ public static string[] Split(string path)
+ => path.Split(new[] { ':', '.' }, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
+}
+
+internal static class MagicJsonFlattener
+{
+ public static Dictionary Flatten(JsonObject root)
+ {
+ var result = new Dictionary(StringComparer.OrdinalIgnoreCase);
+ Visit(root, null, result);
+ return result;
+ }
+
+ private static void Visit(JsonNode? node, string? path, IDictionary result)
+ {
+ switch (node)
+ {
+ case JsonObject obj:
+ foreach (var pair in obj)
+ {
+ if (pair.Key == "$magicSettings")
+ {
+ continue;
+ }
+
+ Visit(pair.Value, path is null ? pair.Key : $"{path}:{pair.Key}", result);
+ }
+ break;
+ case JsonArray array:
+ for (var index = 0; index < array.Count; index++)
+ {
+ Visit(array[index], $"{path}:{index}", result);
+ }
+ break;
+ case JsonValue value when path is not null:
+ result[path] = ToConfigurationString(value);
+ break;
+ case null when path is not null:
+ result[path] = null;
+ break;
+ }
+ }
+
+ private static string? ToConfigurationString(JsonValue value)
+ {
+ if (value.TryGetValue(out var text)) return text;
+ if (value.TryGetValue(out var boolean)) return boolean ? "true" : "false";
+ if (value.TryGetValue(out var integer)) return integer.ToString(CultureInfo.InvariantCulture);
+ if (value.TryGetValue(out var longInteger)) return longInteger.ToString(CultureInfo.InvariantCulture);
+ if (value.TryGetValue(out var number)) return number.ToString(CultureInfo.InvariantCulture);
+ if (value.TryGetValue(out var decimalNumber)) return decimalNumber.ToString(CultureInfo.InvariantCulture);
+ return value.ToJsonString();
+ }
+}
+
+internal static class MagicEnvironmentOverrides
+{
+ public static Dictionary Read(string prefix)
+ {
+ var result = new Dictionary(StringComparer.OrdinalIgnoreCase);
+ foreach (DictionaryEntry entry in Environment.GetEnvironmentVariables())
+ {
+ if (entry.Key is not string name || !name.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))
+ {
+ continue;
+ }
+
+ var path = name[prefix.Length..].Replace("__", ":", StringComparison.Ordinal);
+ result[path] = entry.Value?.ToString();
+ }
+
+ return result;
+ }
+}
+}
+namespace MagicSettings
+{
+public sealed class MagicMigrationContext
+{
+ private readonly List _safeOperations = new();
+ private readonly List _reviewItems = new();
+
+ public IReadOnlyList SafeOperations => _safeOperations;
+ public IReadOnlyList ReviewItems => _reviewItems;
+
+ public void RecordSafe(string operation) => _safeOperations.Add(operation);
+
+ public void RequireReview(string path, string operation, string reason, string? proposedPath = null, MagicMigrationReviewSeverity severity = MagicMigrationReviewSeverity.Warning)
+ => _reviewItems.Add(new(path, operation, severity, reason, proposedPath));
+
+ public void Rename(JsonObject document, string from, string to, bool remoteSafeProjection = true)
+ {
+ if (!MagicJsonPath.TryTake(document, from, out var value))
+ {
+ return;
+ }
+
+ if (MagicJsonPath.TryGet(document, to, out _))
+ {
+ throw new InvalidOperationException($"Cannot rename '{from}' to '{to}' because the destination already exists.");
+ }
+
+ MagicJsonPath.Set(document, to, value);
+ RecordSafe($"rename:{from}->{to}");
+ if (!remoteSafeProjection)
+ {
+ RequireReview(from, "rename", "The remote record should retain the original value until reviewed.", to);
+ }
+ }
+
+ public void Transform(
+ JsonObject document,
+ string path,
+ Func transform,
+ string description,
+ bool remoteSafeProjection = false)
+ {
+ ArgumentNullException.ThrowIfNull(transform);
+ if (!MagicJsonPath.TryGet(document, path, out var current))
+ {
+ return;
+ }
+
+ MagicJsonPath.Set(document, path, transform(current?.DeepClone()));
+ RecordSafe($"transform:{path}:{description}");
+ if (!remoteSafeProjection)
+ {
+ RequireReview(path, "transform", description);
+ }
+ }
+
+ public void SetIfMissing(JsonObject document, string path, JsonNode? value)
+ {
+ if (MagicJsonPath.TryGet(document, path, out _))
+ {
+ return;
+ }
+
+ MagicJsonPath.Set(document, path, value);
+ RecordSafe($"set-if-missing:{path}");
+ }
+
+ public void Remove(JsonObject document, string path, string reason)
+ {
+ if (MagicJsonPath.TryTake(document, path, out _))
+ {
+ RecordSafe($"local-remove:{path}");
+ RequireReview(path, "remove", reason, severity: MagicMigrationReviewSeverity.Destructive);
+ }
+ }
+}
+
+internal static class MagicMigrationRunner
+{
+ public static MagicSettingsMigrationReport? Run(JsonObject document, int currentVersion, int targetVersion, IEnumerable migrations)
+ {
+ if (currentVersion > targetVersion)
+ {
+ throw new InvalidOperationException($"Downgrading settings from schema {currentVersion} to {targetVersion} is not supported.");
+ }
+
+ if (currentVersion == targetVersion)
+ {
+ return null;
+ }
+
+ var ordered = migrations.OrderBy(static migration => migration.FromVersion).ToList();
+ var context = new MagicMigrationContext();
+ var version = currentVersion;
+ while (version < targetVersion)
+ {
+ var migration = ordered.SingleOrDefault(item => item.FromVersion == version)
+ ?? throw new InvalidOperationException($"No migration exists from schema version {version}.");
+ if (migration.ToVersion <= version)
+ {
+ throw new InvalidOperationException($"Migration {migration.GetType().Name} does not advance the schema version.");
+ }
+
+ migration.Apply(document, context);
+ version = migration.ToVersion;
+ }
+
+ if (version != targetVersion)
+ {
+ throw new InvalidOperationException($"Migration chain ended at schema version {version}, expected {targetVersion}.");
+ }
+
+ return new(currentVersion, targetVersion, context.SafeOperations.ToArray(), context.ReviewItems.ToArray());
+ }
+}
+}
diff --git a/MagicSettings/MagicSettings/Source.06.cs b/MagicSettings/MagicSettings/Source.06.cs
new file mode 100644
index 0000000..5cfe2f9
--- /dev/null
+++ b/MagicSettings/MagicSettings/Source.06.cs
@@ -0,0 +1,161 @@
+// Consolidated source file. See repository history and wiki for subsystem boundaries.
+using System.Text.Json.Nodes;
+using MagicSettings.Share;
+using System.Reflection;
+using System.Security.Cryptography;
+using System.Text;
+using System.Text.Json;
+using Microsoft.Extensions.Hosting;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.DependencyInjection;
+using System.Net.Http.Headers;
+using System.Collections.Concurrent;
+using System.Net.Security;
+using System.Security.Cryptography.X509Certificates;
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.Options;
+using System.Collections;
+using System.Globalization;
+using System.ComponentModel.DataAnnotations;
+
+namespace MagicSettings
+{
+public enum MagicFailureAction
+{
+ StopStartup,
+ WarnAndContinue,
+ KeepLastKnownGood
+}
+
+public enum MagicArrayMergePolicy
+{
+ PreserveExisting,
+ ReplaceWithTemplate,
+ AppendMissing,
+ Union
+}
+
+public sealed class MagicSettingsFailurePolicy
+{
+ public bool StrictDevelopmentMode { get; set; } = true;
+ public MagicFailureAction AmbiguousArrayInProduction { get; set; } = MagicFailureAction.WarnAndContinue;
+ public MagicFailureAction RuntimeReloadFailure { get; set; } = MagicFailureAction.KeepLastKnownGood;
+}
+
+public sealed class MagicControlPlaneBootstrapOptions
+{
+ public string EnvironmentVariableName { get; set; } = "MAGICSETTINGS_CONTROL_PLANE_ENDPOINT";
+ public string PersistentSettingPath { get; set; } = "MagicSettings:ControlPlane:Endpoint";
+ public Uri? CodeFallbackEndpoint { get; set; }
+ public MagicControlPlaneTrust? Trust { get; set; }
+ public bool ConnectOnStartup { get; set; }
+ public bool WatchPersistentEndpoint { get; set; } = true;
+ public bool AllowInsecureHttp { get; set; }
+}
+
+public sealed class MagicControlPlaneOptions
+{
+ public MagicControlPlaneBootstrapOptions Bootstrap { get; } = new();
+ public TimeSpan PollInterval { get; set; } = TimeSpan.FromSeconds(90);
+ public TimeSpan PollJitter { get; set; } = TimeSpan.FromSeconds(30);
+ public bool KeepLastKnownGoodDuringOutage { get; set; } = true;
+}
+
+public sealed class MagicSettingsOptions where TSettings : class, new()
+{
+ public string ApplicationId { get; set; } = typeof(TSettings).Assembly.GetName().Name ?? typeof(TSettings).Name;
+ public string ApplicationVersion { get; set; } = typeof(TSettings).Assembly.GetName().Version?.ToString() ?? "0.0.0";
+ public int SchemaVersion { get; set; } = 1;
+ public TSettings Template { get; set; } = new();
+ public string? Path { get; set; }
+ public string FileName { get; set; } = "appsettings.json";
+ public string EnvironmentOverridePrefix { get; set; } = "MagicSettings__";
+ public string Environment { get; set; } = string.Empty;
+ public bool ReloadOnChange { get; set; } = true;
+ public TimeSpan ReloadDebounce { get; set; } = TimeSpan.FromMilliseconds(350);
+ public bool PreserveUnknownProperties { get; set; } = true;
+ public string IdentityFileName { get; set; } = ".magicsettings.identity.json";
+ public string? IdentityPath { get; set; }
+ public JsonSerializerOptions Json { get; } = new() { WriteIndented = true, PropertyNameCaseInsensitive = true };
+ public MagicSettingsFailurePolicy Failures { get; } = new();
+ public MagicControlPlaneOptions ControlPlane { get; } = new();
+ public IDictionary> EnvironmentProfiles { get; } = new Dictionary>(StringComparer.OrdinalIgnoreCase);
+ public IList Migrations { get; } = new List();
+ public IList> Validators { get; } = new List>();
+ public IList Providers { get; } = new List();
+ public IDictionary ArrayPolicies { get; } = new Dictionary(StringComparer.OrdinalIgnoreCase);
+ public ISet SensitivePaths { get; } = new HashSet(StringComparer.OrdinalIgnoreCase);
+ public IMagicControlPlaneTransport? ControlPlaneTransport { get; set; }
+ public IMagicControlPlaneEndpointResolver? ControlPlaneEndpointResolver { get; set; }
+ public IMagicNodeIdentityStore? IdentityStore { get; set; }
+
+ public void ConfigureEnvironment(string environment, Action configure)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(environment);
+ ArgumentNullException.ThrowIfNull(configure);
+ EnvironmentProfiles[environment] = configure;
+ }
+}
+
+public sealed record MagicSettingsInitializationResult(bool ShouldExit, int ExitCode, string SettingsPath, string Environment);
+}
+namespace MagicSettings
+{
+public static class MagicSettingsEnvironmentResolver
+{
+ public static string Resolve(string? explicitEnvironment = null, string? hostEnvironment = null)
+ => FirstNonEmpty(
+ explicitEnvironment,
+ Environment.GetEnvironmentVariable("MAGICSETTINGS_ENVIRONMENT"),
+ Environment.GetEnvironmentVariable("DOTNET_ENVIRONMENT"),
+ Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT"),
+ hostEnvironment,
+ "Production")!;
+
+ private static string? FirstNonEmpty(params string?[] values)
+ => values.FirstOrDefault(static value => !string.IsNullOrWhiteSpace(value));
+}
+
+public static class MagicSettingsPathResolver
+{
+ public static string Resolve(MagicSettingsOptions options) where TSettings : class, new()
+ {
+ var configured = options.Path;
+ if (string.IsNullOrWhiteSpace(configured))
+ {
+ configured = Environment.GetEnvironmentVariable("MAGICSETTINGS_PATH");
+ }
+
+ if (string.IsNullOrWhiteSpace(configured))
+ {
+ return Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, options.FileName));
+ }
+
+ var fullPath = Path.GetFullPath(configured);
+ if (Path.HasExtension(fullPath) && string.Equals(Path.GetExtension(fullPath), ".json", StringComparison.OrdinalIgnoreCase))
+ {
+ return fullPath;
+ }
+
+ return Path.Combine(fullPath, options.FileName);
+ }
+}
+
+
+public static class MagicIdentityPathResolver
+{
+ public static string Resolve(string settingsPath, string? configuredPath, string fileName)
+ {
+ var configured = string.IsNullOrWhiteSpace(configuredPath)
+ ? Environment.GetEnvironmentVariable("MAGICSETTINGS_IDENTITY_PATH")
+ : configuredPath;
+ if (string.IsNullOrWhiteSpace(configured))
+ {
+ return Path.GetFullPath(Path.Combine(Path.GetDirectoryName(settingsPath) ?? AppContext.BaseDirectory, fileName));
+ }
+
+ var fullPath = Path.GetFullPath(configured);
+ return Path.HasExtension(fullPath) ? fullPath : Path.Combine(fullPath, fileName);
+ }
+}
+}
diff --git a/MagicSettings/MagicSettings/Source.07.cs b/MagicSettings/MagicSettings/Source.07.cs
new file mode 100644
index 0000000..ba79bd8
--- /dev/null
+++ b/MagicSettings/MagicSettings/Source.07.cs
@@ -0,0 +1,405 @@
+// Consolidated source file. See repository history and wiki for subsystem boundaries.
+using System.Text.Json.Nodes;
+using MagicSettings.Share;
+using System.Reflection;
+using System.Security.Cryptography;
+using System.Text;
+using System.Text.Json;
+using Microsoft.Extensions.Hosting;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.DependencyInjection;
+using System.Net.Http.Headers;
+using System.Collections.Concurrent;
+using System.Net.Security;
+using System.Security.Cryptography.X509Certificates;
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.Options;
+using System.Collections;
+using System.Globalization;
+using System.ComponentModel.DataAnnotations;
+
+namespace MagicSettings
+{
+internal sealed class MagicSettingsConfigurationSource : IConfigurationSource
+{
+ private readonly MagicSettingsConfigurationProvider _provider;
+ public MagicSettingsConfigurationSource(MagicSettingsConfigurationProvider provider) => _provider = provider;
+ public IConfigurationProvider Build(IConfigurationBuilder builder) => _provider;
+}
+
+internal sealed class MagicSettingsConfigurationProvider : ConfigurationProvider
+{
+ public void Publish(IReadOnlyDictionary values)
+ {
+ Data = new Dictionary(values, StringComparer.OrdinalIgnoreCase);
+ OnReload();
+ }
+}
+
+internal sealed class MagicSettingsRuntime : IMagicSettings where TSettings : class, new()
+{
+ private readonly object _gate = new();
+ private readonly MagicSettingsOptions _options;
+ private readonly MagicSettingsConfigurationProvider _configurationProvider;
+ private readonly string _settingsPath;
+ private Dictionary _persistent = new(StringComparer.OrdinalIgnoreCase);
+ private Dictionary _environment = new(StringComparer.OrdinalIgnoreCase);
+ private Dictionary _remote = new(StringComparer.OrdinalIgnoreCase);
+ private MagicRemoteSnapshot _remoteSnapshot = MagicRemoteSnapshot.Empty;
+ private Dictionary _providers = new(StringComparer.OrdinalIgnoreCase);
+ private Dictionary _effective = new(StringComparer.OrdinalIgnoreCase);
+ private TSettings _current = new();
+ private long _revision;
+
+ public MagicSettingsRuntime(MagicSettingsOptions options, MagicSettingsConfigurationProvider configurationProvider, string settingsPath)
+ {
+ _options = options;
+ _configurationProvider = configurationProvider;
+ _settingsPath = settingsPath;
+ }
+
+ public TSettings Current { get { lock (_gate) return _current; } }
+ public long Revision { get { lock (_gate) return _revision; } }
+ public event EventHandler>? Changed;
+
+ public async ValueTask InitializeAsync(JsonObject document, CancellationToken cancellationToken)
+ {
+ var persistent = MagicJsonFlattener.Flatten(document);
+ var environment = MagicEnvironmentOverrides.Read(_options.EnvironmentOverridePrefix);
+ var providers = await LoadCustomProvidersAsync(cancellationToken);
+ PublishCandidate(persistent: persistent, environment: environment, providers: providers);
+ }
+
+ public async ValueTask ReloadPersistentAsync(CancellationToken cancellationToken)
+ {
+ var document = await MagicSettingsDocumentStore.ReadAsync(_settingsPath, cancellationToken);
+ var persistent = MagicJsonFlattener.Flatten(document);
+ var providers = await LoadCustomProvidersAsync(cancellationToken);
+ PublishCandidate(persistent: persistent, providers: providers);
+ }
+
+ public void RefreshEnvironment()
+ => PublishCandidate(environment: MagicEnvironmentOverrides.Read(_options.EnvironmentOverridePrefix));
+
+ public void ApplyRemoteSnapshot(MagicRemoteSnapshot snapshot, DateTimeOffset nowUtc)
+ {
+ ArgumentNullException.ThrowIfNull(snapshot);
+ var values = MaterializeRemote(snapshot, nowUtc);
+ PublishCandidate(remote: values, remoteSnapshot: snapshot, updateRemoteSnapshot: true);
+ }
+
+ public void RefreshRemoteExpirations(DateTimeOffset nowUtc)
+ {
+ MagicRemoteSnapshot snapshot;
+ Dictionary current;
+ lock (_gate)
+ {
+ snapshot = _remoteSnapshot;
+ current = _remote;
+ }
+
+ var values = MaterializeRemote(snapshot, nowUtc);
+ if (!DictionaryEquals(current, values))
+ {
+ PublishCandidate(remote: values);
+ }
+ }
+
+ public void ClearRemote()
+ {
+ PublishCandidate(
+ remote: new Dictionary(StringComparer.OrdinalIgnoreCase),
+ remoteSnapshot: MagicRemoteSnapshot.Empty,
+ updateRemoteSnapshot: true);
+ }
+
+ public MagicSettingExplanation Explain(string path)
+ {
+ lock (_gate)
+ {
+ var sensitive = _options.SensitivePaths.Contains(path);
+ var sources = new[]
+ {
+ SourceValue("Remote", _remote, path, sensitive),
+ SourceValue("Environment", _environment, path, sensitive),
+ SourceValue("CustomProviders", _providers, path, sensitive),
+ SourceValue("PersistentFile", _persistent, path, sensitive)
+ };
+ _effective.TryGetValue(path, out var effective);
+ var source = sources.FirstOrDefault(static item => item.Present)?.Source ?? "Missing";
+ return new(path, sensitive && effective is not null ? "[REDACTED]" : effective, source, sources, sensitive);
+ }
+ }
+
+ private async ValueTask> LoadCustomProvidersAsync(CancellationToken cancellationToken)
+ {
+ var result = new Dictionary(StringComparer.OrdinalIgnoreCase);
+ foreach (var provider in _options.Providers.OrderBy(static item => item.Priority))
+ {
+ var values = await provider.LoadAsync(cancellationToken);
+ foreach (var pair in values)
+ {
+ result[pair.Key] = pair.Value;
+ }
+ }
+
+ return result;
+ }
+
+ private void PublishCandidate(
+ Dictionary? persistent = null,
+ Dictionary? providers = null,
+ Dictionary? environment = null,
+ Dictionary? remote = null,
+ MagicRemoteSnapshot? remoteSnapshot = null,
+ bool updateRemoteSnapshot = false)
+ {
+ TSettings previous;
+ TSettings current;
+ long revision;
+ Dictionary effective;
+ lock (_gate)
+ {
+ var nextPersistent = persistent ?? _persistent;
+ var nextProviders = providers ?? _providers;
+ var nextEnvironment = environment ?? _environment;
+ var nextRemote = remote ?? _remote;
+
+ effective = new Dictionary(nextPersistent, StringComparer.OrdinalIgnoreCase);
+ Merge(effective, nextProviders);
+ Merge(effective, nextEnvironment);
+ Merge(effective, nextRemote);
+
+ var builder = new ConfigurationBuilder().AddInMemoryCollection(effective);
+ current = builder.Build().Get() ?? new TSettings();
+ Validate(current);
+
+ previous = _current;
+ _persistent = nextPersistent;
+ _providers = nextProviders;
+ _environment = nextEnvironment;
+ _remote = nextRemote;
+ if (updateRemoteSnapshot)
+ {
+ _remoteSnapshot = remoteSnapshot ?? MagicRemoteSnapshot.Empty;
+ }
+ _current = current;
+ _effective = effective;
+ revision = ++_revision;
+ }
+
+ _configurationProvider.Publish(effective);
+ Changed?.Invoke(this, new(previous, current, revision));
+ }
+
+ private void Validate(TSettings settings)
+ {
+ var dataAnnotationFailures = new List();
+ ValidateDataAnnotations(settings, string.Empty, dataAnnotationFailures, new HashSet