fix(secrets): SqlServer/Akka extensions take contentRootPath; last explicit root wins (scadaproj#31)

The SQL-Server extensions called the 3-arg AddZbSecrets, and the store-path
validator captured the content root of whichever call registered it first.
In hub mode no registration order applied the pre-host under-content-root
guard: extension-first dropped the app's 4-arg root, core-first lost the
replicator (#30). That is the 0.6.0 store-path hole, reopened.

- AddZbSecretsSqlServerStore / AddZbSecretsSqlServerReplication /
  AddZbSecretsAkkaReplication gain a trailing optional contentRootPath,
  forwarded to the 4-arg AddZbSecrets
- the root now lives in one SecretsContentRoot instance holder per service
  collection; the last explicit root wins, null never erases one; the
  IHostEnvironment fallback is unchanged
- tests: ContentRootTests (hub mode, three compositions, artifact asserted
  before the throw, positive control), last-explicit-wins theory in
  SecretsStorePathRulesTests, Akka forwarding test
- code-reviews: Secrets-002 Resolved; README regenerated

Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
This commit is contained in:
Joseph Doherty
2026-11-09 14:50:07 -05:00
parent 015c556ced
commit c3b07d6766
10 changed files with 272 additions and 18 deletions
@@ -39,16 +39,23 @@ public static class SqlServerSecretsServiceCollectionExtensions
/// <param name="config">The application configuration.</param>
/// <param name="secretsSectionPath">Configuration section holding the core secrets options.</param>
/// <param name="sqlServerSectionPath">Configuration section holding the SQL-Server options.</param>
/// <param name="contentRootPath">
/// The application content root, forwarded to <c>AddZbSecrets</c> so the store-path guard can
/// reject a <c>Secrets:SqlitePath</c> inside it. Pass it from every pre-host composition (a
/// container with no <c>IHostEnvironment</c>, such as a <c>${secret:}</c> expander's), exactly as
/// for the 4-argument <c>AddZbSecrets</c>. The last explicitly supplied root wins across calls.
/// </param>
/// <returns>The same <paramref name="services"/> instance, for chaining.</returns>
public static IServiceCollection AddZbSecretsSqlServerStore(
this IServiceCollection services,
IConfiguration config,
string secretsSectionPath = "Secrets",
string sqlServerSectionPath = "Secrets:SqlServer")
string sqlServerSectionPath = "Secrets:SqlServer",
string? contentRootPath = null)
{
RegisterSqlServerCore(services, config, sqlServerSectionPath);
services.AddZbSecrets(config, secretsSectionPath);
services.AddZbSecrets(config, secretsSectionPath, contentRootPath);
// REPLACE, not TryAdd (scadaproj#30): the application may already have called AddZbSecrets,
// whose SQLite ISecretStore / ISecretsStoreMigrator a TryAdd here would silently leave in
@@ -81,19 +88,26 @@ public static class SqlServerSecretsServiceCollectionExtensions
/// <param name="config">The application configuration.</param>
/// <param name="secretsSectionPath">Configuration section holding the core secrets options.</param>
/// <param name="sqlServerSectionPath">Configuration section holding the SQL-Server options.</param>
/// <param name="contentRootPath">
/// The application content root, forwarded to <c>AddZbSecrets</c> so the store-path guard can
/// reject a <c>Secrets:SqlitePath</c> inside it. Pass it from every pre-host composition (a
/// container with no <c>IHostEnvironment</c>, such as a <c>${secret:}</c> expander's), exactly as
/// for the 4-argument <c>AddZbSecrets</c>. The last explicitly supplied root wins across calls.
/// </param>
/// <returns>The same <paramref name="services"/> instance, for chaining.</returns>
public static IServiceCollection AddZbSecretsSqlServerReplication(
this IServiceCollection services,
IConfiguration config,
string secretsSectionPath = "Secrets",
string sqlServerSectionPath = "Secrets:SqlServer")
string sqlServerSectionPath = "Secrets:SqlServer",
string? contentRootPath = null)
{
RegisterSqlServerCore(services, config, sqlServerSectionPath);
services.TryAddSingleton<ISecretReplicationHub>(sp => sp.GetRequiredService<SqlServerSecretStore>());
// The local store + its migrator come from AddZbSecrets (SQLite by default).
services.AddZbSecrets(config, secretsSectionPath);
services.AddZbSecrets(config, secretsSectionPath, contentRootPath);
// REPLACE, not TryAdd (scadaproj#30): if the application called AddZbSecrets first, its
// NoOpSecretReplicator is already registered and a TryAdd would silently lose to it — live