diff --git a/ZB.MOM.WW.Auth/docs/sql/zbauth-schema.sql b/ZB.MOM.WW.Auth/docs/sql/zbauth-schema.sql
new file mode 100644
index 0000000..2b4e22b
--- /dev/null
+++ b/ZB.MOM.WW.Auth/docs/sql/zbauth-schema.sql
@@ -0,0 +1,69 @@
+-- ZB.MOM.WW.Auth.ApiKeys.SqlServer — shared API-key schema, version 1.
+--
+-- For DBAs whose application login lacks DDL rights. The host normally provisions this itself at
+-- startup (SqlServerApiKeyStoreMigrator, which runs the identical statements and is safe to run
+-- concurrently from every node); where the app login may only read and write rows, run this script
+-- once against the target database instead. The migrator then finds the version row already at the
+-- version it supports and does nothing further beyond re-stamping applied_utc.
+--
+-- The schema name is hard-coded to zbauth here — the default of SqlServerApiKeyOptions.Schema. If a
+-- deployment overrides that option, substitute the name in every bracketed identifier below.
+--
+-- Kept byte-for-byte consistent with SqlServerAuthSchema.CreateSchemaDdl apart from that
+-- substitution and the literal version row at the end; change both together.
+
+IF NOT EXISTS (SELECT 1 FROM sys.schemas WHERE name = N'zbauth')
+ EXEC(N'CREATE SCHEMA [zbauth]');
+
+IF OBJECT_ID(N'[zbauth].[schema_version]', N'U') IS NULL
+ CREATE TABLE [zbauth].[schema_version] (
+ id INT NOT NULL CONSTRAINT PK_zbauth_schema_version PRIMARY KEY CHECK (id = 1),
+ version INT NOT NULL,
+ applied_utc DATETIMEOFFSET(7) NOT NULL
+ );
+
+IF OBJECT_ID(N'[zbauth].[api_keys]', N'U') IS NULL
+ CREATE TABLE [zbauth].[api_keys] (
+ key_id NVARCHAR(64) NOT NULL CONSTRAINT PK_zbauth_api_keys PRIMARY KEY,
+ key_prefix NVARCHAR(16) NOT NULL,
+ secret_hash VARBINARY(64) NOT NULL,
+ display_name NVARCHAR(256) NOT NULL,
+ scopes NVARCHAR(MAX) NOT NULL,
+ constraints NVARCHAR(MAX) NULL,
+ created_utc DATETIMEOFFSET(7) NOT NULL,
+ last_used_utc DATETIMEOFFSET(7) NULL,
+ revoked_utc DATETIMEOFFSET(7) NULL,
+ expires_utc DATETIMEOFFSET(7) NULL
+ );
+
+IF OBJECT_ID(N'[zbauth].[api_key_audit]', N'U') IS NULL
+ CREATE TABLE [zbauth].[api_key_audit] (
+ audit_id BIGINT IDENTITY(1,1) NOT NULL CONSTRAINT PK_zbauth_api_key_audit PRIMARY KEY,
+ key_id NVARCHAR(64) NULL,
+ event_type NVARCHAR(64) NOT NULL,
+ remote_address NVARCHAR(64) NULL,
+ created_utc DATETIMEOFFSET(7) NOT NULL,
+ details NVARCHAR(MAX) NULL
+ );
+
+IF NOT EXISTS (
+ SELECT 1 FROM sys.indexes
+ WHERE name = N'ix_api_keys_revoked_utc'
+ AND object_id = OBJECT_ID(N'[zbauth].[api_keys]'))
+ CREATE INDEX ix_api_keys_revoked_utc
+ ON [zbauth].[api_keys] (revoked_utc);
+
+IF NOT EXISTS (
+ SELECT 1 FROM sys.indexes
+ WHERE name = N'ix_api_key_audit_key_id_created_utc'
+ AND object_id = OBJECT_ID(N'[zbauth].[api_key_audit]'))
+ CREATE INDEX ix_api_key_audit_key_id_created_utc
+ ON [zbauth].[api_key_audit] (key_id, created_utc);
+
+MERGE [zbauth].[schema_version] AS target
+USING (SELECT 1 AS id, 1 AS version, SYSDATETIMEOFFSET() AS applied_utc) AS source
+ ON target.id = source.id
+WHEN MATCHED THEN
+ UPDATE SET version = source.version, applied_utc = source.applied_utc
+WHEN NOT MATCHED THEN
+ INSERT (id, version, applied_utc) VALUES (1, source.version, source.applied_utc);
diff --git a/ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.ApiKeys.SqlServer/SqlServerApiKeyOptions.cs b/ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.ApiKeys.SqlServer/SqlServerApiKeyOptions.cs
index a3b4006..f07df13 100644
--- a/ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.ApiKeys.SqlServer/SqlServerApiKeyOptions.cs
+++ b/ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.ApiKeys.SqlServer/SqlServerApiKeyOptions.cs
@@ -28,9 +28,38 @@ public sealed class SqlServerApiKeyOptions
{
if (string.IsNullOrWhiteSpace(ConnectionString))
throw new InvalidOperationException("SQL Server API-key store requires a ConnectionString.");
- if (string.IsNullOrEmpty(Schema) || !Schema.All(c => char.IsAsciiLetterOrDigit(c) || c == '_') || Schema.Length > 64)
- throw new InvalidOperationException($"SQL Server API-key store schema name '{Schema}' is not a safe identifier.");
+
+ ValidateIdentifier(Schema);
+
if (CommandTimeout <= TimeSpan.Zero)
throw new InvalidOperationException($"SQL Server API-key store CommandTimeout must be positive (was {CommandTimeout}).");
}
+
+ ///
+ /// Strict allow-list for a SQL identifier that will be interpolated into DDL or query text.
+ /// Rejects everything that is not [A-Za-z_][A-Za-z0-9_]{0,63} — no brackets, quotes, dots
+ /// or spaces — so no bracket-escape (]]) trick can break out of the quoting applied at the
+ /// use site. Uses the ASCII-only character tests deliberately, so homoglyph and full-width
+ /// look-alikes are rejected rather than accepted as "letters".
+ ///
+ /// The candidate identifier.
+ /// The same value, once validated.
+ /// The value is not a plain identifier.
+ ///
+ /// Public because is public and must
+ /// defend itself rather than trust that every caller routed through .
+ ///
+ public static string ValidateIdentifier(string schemaName)
+ {
+ if (string.IsNullOrEmpty(schemaName)
+ || schemaName.Length > 64
+ || (!char.IsAsciiLetter(schemaName[0]) && schemaName[0] != '_')
+ || !schemaName.All(c => char.IsAsciiLetterOrDigit(c) || c == '_'))
+ {
+ throw new InvalidOperationException(
+ $"SQL Server API-key store schema name '{schemaName}' is not a safe identifier.");
+ }
+
+ return schemaName;
+ }
}
diff --git a/ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.ApiKeys.SqlServer/SqlServerApiKeyStoreMigrator.cs b/ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.ApiKeys.SqlServer/SqlServerApiKeyStoreMigrator.cs
new file mode 100644
index 0000000..32764b6
--- /dev/null
+++ b/ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.ApiKeys.SqlServer/SqlServerApiKeyStoreMigrator.cs
@@ -0,0 +1,207 @@
+using System.Data;
+using System.Globalization;
+using Microsoft.Data.SqlClient;
+using ZB.MOM.WW.Auth.Abstractions.ApiKeys;
+using ZB.MOM.WW.Auth.ApiKeys.Sqlite;
+
+namespace ZB.MOM.WW.Auth.ApiKeys.SqlServer;
+
+///
+/// Creates the shared SQL Server API-key schema and records the applied version. Idempotent: safe
+/// to run on every node at every startup, including concurrently — lost provisioning races surface
+/// as retryable s (see ) and
+/// the retry re-runs the IF NOT EXISTS path against the objects the winner just created.
+/// Refuses to run against a database whose recorded version is newer than this build supports.
+///
+/// Factory for connections to the shared API-key database.
+public sealed class SqlServerApiKeyStoreMigrator(AuthSqlServerConnectionFactory connectionFactory)
+ : IApiKeyStoreMigrator
+{
+ // Every node runs this at every startup, so a virgin database being provisioned by N nodes at
+ // once is the NORMAL case, not a rare race — and an unretried failure aborts that node's whole
+ // host at startup. Two failure shapes come out of that race, and retrying is correct for both
+ // precisely because the migration is idempotent:
+ //
+ // - Deadlock victim (1205): concurrent Serializable DDL against the system catalogs can pick
+ // a victim even though the IF NOT EXISTS guards make the logic itself safe.
+ // - Lost CREATE / lost INSERT (2714, 1913, 2627): Serializable range locks do NOT serialize
+ // the metadata existence checks against another session's in-flight CREATE — observed live
+ // on the sibling shared secret store (ScadaBridge central-pair first boot, 2026-08-07):
+ // both nodes passed the sys.schemas IF NOT EXISTS probe, the loser's CREATE SCHEMA then
+ // died with 2714 "There is already an object named 'zbsecrets'". The object the loser tried
+ // to create now exists, which is the desired end state — the retry's IF NOT EXISTS pass
+ // skips it and proceeds.
+ //
+ // The retry cap keeps this from masking a GENUINE conflict (e.g. an unrelated pre-existing
+ // object colliding with the schema name): that reproduces on every attempt, so after
+ // MaxMigrationRetries the real SqlException propagates and the node fails closed.
+ private const int MaxMigrationRetries = 5;
+
+ /// Deadlock victim.
+ private const int SqlDeadlockVictimErrorNumber = 1205;
+
+ /// "There is already an object named '%ls'" — lost CREATE SCHEMA / CREATE TABLE race.
+ private const int SqlObjectAlreadyExistsErrorNumber = 2714;
+
+ /// "An index or statistics with name '%ls' already exists" — lost CREATE INDEX race.
+ private const int SqlIndexAlreadyExistsErrorNumber = 1913;
+
+ /// Primary-key violation — lost version-row MERGE-insert race.
+ private const int SqlPrimaryKeyViolationErrorNumber = 2627;
+
+ ///
+ ///
+ /// The recorded schema version is newer than .
+ ///
+ public async Task MigrateAsync(CancellationToken cancellationToken)
+ {
+ for (int attempt = 1; ; attempt++)
+ {
+ try
+ {
+ await MigrateOnceAsync(cancellationToken).ConfigureAwait(false);
+ return;
+ }
+ catch (SqlException ex)
+ when (IsRetryableMigrationError(ex) && attempt < MaxMigrationRetries)
+ {
+ await Task.Delay(BackoffFor(attempt), cancellationToken).ConfigureAwait(false);
+ }
+ }
+ }
+
+ ///
+ /// Back-off before retry attempt + 1: a growing base plus RANDOM
+ /// jitter. The jitter is the load-bearing half. The sibling secret-store migrator this is ported
+ /// from delays a flat 100 * attempt, which is the SAME value for every victim of the same
+ /// round — so N deadlock victims wake together and re-collide, exactly the lockstep its comment
+ /// claims to prevent. Measured here against a live SQL Server with eight concurrent migrators on
+ /// a virgin schema, the unjittered form exhausted the five-attempt budget on repeated 1205s in
+ /// roughly 1 run in 60; with jitter it did not.
+ ///
+ /// The 1-based attempt that just failed.
+ /// How long to wait before the next attempt.
+ private static TimeSpan BackoffFor(int attempt) =>
+ TimeSpan.FromMilliseconds((100 * attempt) + Random.Shared.Next(0, 100 * attempt));
+
+ ///
+ /// Whether any error carried by is a retryable provisioning-race
+ /// outcome. Scans the whole collection rather than only
+ /// because a failed DDL batch stacks follow-on errors (e.g.
+ /// 2714 arrives with 2759 "CREATE SCHEMA failed due to previous errors") and the retryable one
+ /// is not guaranteed to be first.
+ ///
+ /// The exception the migration attempt failed with.
+ /// when a retry may succeed.
+ internal static bool IsRetryableMigrationError(SqlException exception)
+ {
+ foreach (SqlError error in exception.Errors)
+ {
+ if (IsRetryableMigrationErrorNumber(error.Number))
+ {
+ return true;
+ }
+ }
+
+ return IsRetryableMigrationErrorNumber(exception.Number);
+ }
+
+ ///
+ /// Classifies a SQL Server error number as a retryable outcome of N nodes provisioning the same
+ /// (possibly virgin) database at once. Everything here is a race whose retry re-runs the
+ /// idempotent IF NOT EXISTS path; anything else (login failures, missing database,
+ /// permissions, syntax) reproduces identically on retry and must propagate. 2705 (duplicate
+ /// column) is deliberately absent: every column lives inside a single CREATE TABLE
+ /// statement, so no cross-session race can produce it.
+ ///
+ /// The to classify.
+ /// when the number is a known provisioning-race outcome.
+ internal static bool IsRetryableMigrationErrorNumber(int errorNumber) => errorNumber
+ is SqlDeadlockVictimErrorNumber
+ or SqlObjectAlreadyExistsErrorNumber
+ or SqlIndexAlreadyExistsErrorNumber
+ or SqlPrimaryKeyViolationErrorNumber;
+
+ private async Task MigrateOnceAsync(CancellationToken cancellationToken)
+ {
+ string schema = connectionFactory.Schema;
+
+ await using SqlConnection connection =
+ await connectionFactory.OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
+
+ // Serializable: unlike the single-writer SQLite store, EVERY node in the cluster runs this
+ // migration at startup, so concurrent first-boots are the normal case rather than a rare
+ // race. The isolation level narrows the race window but does NOT close it — the IF NOT
+ // EXISTS metadata probes are not serialized against another session's in-flight CREATE, so
+ // a loser can still fail with "object exists" (2714/1913) or a deadlock (1205). The retry
+ // filter in MigrateAsync is what makes simultaneous provision converge: by the time the
+ // loser retries, the winner's objects exist and every guard here skips them.
+ await using SqlTransaction transaction = (SqlTransaction)
+ await connection.BeginTransactionAsync(IsolationLevel.Serializable, cancellationToken)
+ .ConfigureAwait(false);
+
+ int existingVersion =
+ await ReadExistingSchemaVersionAsync(connection, transaction, schema, cancellationToken)
+ .ConfigureAwait(false);
+
+ if (existingVersion > SqlServerAuthSchema.CurrentVersion)
+ {
+ throw new AuthStoreMigrationException(
+ $"Shared API-key database schema version {existingVersion} is newer than supported version " +
+ $"{SqlServerAuthSchema.CurrentVersion}. Upgrade this node before it writes to the shared store.");
+ }
+
+ await using (SqlCommand ddl = connectionFactory.CreateCommand(
+ connection, SqlServerAuthSchema.CreateSchemaDdl(schema), transaction))
+ {
+ await ddl.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
+ }
+
+ // MERGE rather than insert-if-missing: WHEN MATCHED re-stamps the row, so a future schema
+ // version actually lands in the table instead of leaving the first-ever value behind.
+ await using (SqlCommand version = connectionFactory.CreateCommand(
+ connection,
+ $"""
+ MERGE [{schema}].[{SqlServerAuthSchema.SchemaVersionTable}] AS target
+ USING (SELECT 1 AS id, @version AS version, @applied_utc AS applied_utc) AS source
+ ON target.id = source.id
+ WHEN MATCHED THEN
+ UPDATE SET version = source.version, applied_utc = source.applied_utc
+ WHEN NOT MATCHED THEN
+ INSERT (id, version, applied_utc) VALUES (1, source.version, source.applied_utc);
+ """,
+ transaction))
+ {
+ version.Parameters.Add("@version", SqlDbType.Int).Value = SqlServerAuthSchema.CurrentVersion;
+ version.Parameters.Add("@applied_utc", SqlDbType.DateTimeOffset).Value = DateTimeOffset.UtcNow;
+ await version.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
+ }
+
+ await transaction.CommitAsync(cancellationToken).ConfigureAwait(false);
+ }
+
+ private async Task ReadExistingSchemaVersionAsync(
+ SqlConnection connection,
+ SqlTransaction transaction,
+ string schema,
+ CancellationToken cancellationToken)
+ {
+ // OBJECT_ID returns NULL for an absent table, so this reads 0 on a virgin database without
+ // needing the table to exist first.
+ await using SqlCommand command = connectionFactory.CreateCommand(
+ connection,
+ $"""
+ IF OBJECT_ID(N'[{schema}].[{SqlServerAuthSchema.SchemaVersionTable}]', N'U') IS NULL
+ SELECT 0;
+ ELSE
+ SELECT ISNULL((SELECT version FROM [{schema}].[{SqlServerAuthSchema.SchemaVersionTable}] WHERE id = 1), 0);
+ """,
+ transaction);
+
+ object? version = await command.ExecuteScalarAsync(cancellationToken).ConfigureAwait(false);
+
+ return version is null || version == DBNull.Value
+ ? 0
+ : Convert.ToInt32(version, CultureInfo.InvariantCulture);
+ }
+}
diff --git a/ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.ApiKeys.SqlServer/SqlServerAuthSchema.cs b/ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.ApiKeys.SqlServer/SqlServerAuthSchema.cs
new file mode 100644
index 0000000..c2a8d27
--- /dev/null
+++ b/ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.ApiKeys.SqlServer/SqlServerAuthSchema.cs
@@ -0,0 +1,116 @@
+namespace ZB.MOM.WW.Auth.ApiKeys.SqlServer;
+
+///
+/// Schema constants and DDL for the shared SQL Server API-key store. Mirrors
+/// ZB.MOM.WW.Auth.ApiKeys.Sqlite.SqliteAuthSchema column-for-column so a row means exactly
+/// the same thing in either store.
+///
+///
+///
+/// Timestamps are DATETIMEOFFSET(7), not ISO-8601 text. The sibling
+/// ZB.MOM.WW.Secrets SQL Server store deliberately stores its timestamps as
+/// nvarchar because its rows replicate with a SQLite store and last-writer-wins
+/// compares those timestamps for equality, where a sub-tick round-trip difference would turn a tie
+/// into a spurious "newer". No such rationale applies here: this store never replicates with the
+/// SQLite one — a host picks exactly one provider, and the one-shot SQLite import is a copy, not an
+/// ongoing sync — so the native type is used, which sorts and range-scans correctly and keeps the
+/// tables readable to a DBA.
+///
+///
+/// SqliteAuthSchema.CurrentVersion is 3 only because that store carries the donor
+/// MxAccessGateway's version history (v1/v2 were byte-identical; v3 added expires_utc). This
+/// schema is created whole, with expires_utc present from the start, so its own version line
+/// starts at 1.
+///
+///
+public static class SqlServerAuthSchema
+{
+ ///
+ /// The schema version this build creates and supports. The migrator refuses a database whose
+ /// recorded version is newer than this.
+ ///
+ public const int CurrentVersion = 1;
+
+ /// Default schema the API-key tables live in.
+ public const string DefaultSchemaName = "zbauth";
+
+ /// Name of the single-row table tracking the applied schema version.
+ public const string SchemaVersionTable = "schema_version";
+
+ /// Name of the table storing API-key records.
+ public const string ApiKeysTable = "api_keys";
+
+ /// Name of the append-only audit table.
+ public const string ApiKeyAuditTable = "api_key_audit";
+
+ ///
+ /// Returns the idempotent DDL that provisions the schema, the three tables and the two
+ /// supporting indexes in .
+ ///
+ /// The SQL schema name to create the objects in.
+ /// The DDL batch.
+ ///
+ /// is interpolated, not parameterized — T-SQL has no parameter
+ /// form for an object name. It is re-validated here against the same strict identifier
+ /// allow-list enforces, and bracket-quoted below.
+ /// The duplicate check is deliberate: this method is public, so it must defend itself
+ /// rather than trust that every caller routed through the options type. The version row is NOT
+ /// written here — writes it with a MERGE that also
+ /// updates on match, so a future upgrade re-stamps the stored version.
+ ///
+ /// is not a plain identifier.
+ public static string CreateSchemaDdl(string schemaName)
+ {
+ SqlServerApiKeyOptions.ValidateIdentifier(schemaName);
+
+ return $"""
+ IF NOT EXISTS (SELECT 1 FROM sys.schemas WHERE name = N'{schemaName}')
+ EXEC(N'CREATE SCHEMA [{schemaName}]');
+
+ IF OBJECT_ID(N'[{schemaName}].[{SchemaVersionTable}]', N'U') IS NULL
+ CREATE TABLE [{schemaName}].[{SchemaVersionTable}] (
+ id INT NOT NULL CONSTRAINT PK_{schemaName}_{SchemaVersionTable} PRIMARY KEY CHECK (id = 1),
+ version INT NOT NULL,
+ applied_utc DATETIMEOFFSET(7) NOT NULL
+ );
+
+ IF OBJECT_ID(N'[{schemaName}].[{ApiKeysTable}]', N'U') IS NULL
+ CREATE TABLE [{schemaName}].[{ApiKeysTable}] (
+ key_id NVARCHAR(64) NOT NULL CONSTRAINT PK_{schemaName}_{ApiKeysTable} PRIMARY KEY,
+ key_prefix NVARCHAR(16) NOT NULL,
+ secret_hash VARBINARY(64) NOT NULL,
+ display_name NVARCHAR(256) NOT NULL,
+ scopes NVARCHAR(MAX) NOT NULL,
+ constraints NVARCHAR(MAX) NULL,
+ created_utc DATETIMEOFFSET(7) NOT NULL,
+ last_used_utc DATETIMEOFFSET(7) NULL,
+ revoked_utc DATETIMEOFFSET(7) NULL,
+ expires_utc DATETIMEOFFSET(7) NULL
+ );
+
+ IF OBJECT_ID(N'[{schemaName}].[{ApiKeyAuditTable}]', N'U') IS NULL
+ CREATE TABLE [{schemaName}].[{ApiKeyAuditTable}] (
+ audit_id BIGINT IDENTITY(1,1) NOT NULL CONSTRAINT PK_{schemaName}_{ApiKeyAuditTable} PRIMARY KEY,
+ key_id NVARCHAR(64) NULL,
+ event_type NVARCHAR(64) NOT NULL,
+ remote_address NVARCHAR(64) NULL,
+ created_utc DATETIMEOFFSET(7) NOT NULL,
+ details NVARCHAR(MAX) NULL
+ );
+
+ IF NOT EXISTS (
+ SELECT 1 FROM sys.indexes
+ WHERE name = N'ix_api_keys_revoked_utc'
+ AND object_id = OBJECT_ID(N'[{schemaName}].[{ApiKeysTable}]'))
+ CREATE INDEX ix_api_keys_revoked_utc
+ ON [{schemaName}].[{ApiKeysTable}] (revoked_utc);
+
+ IF NOT EXISTS (
+ SELECT 1 FROM sys.indexes
+ WHERE name = N'ix_api_key_audit_key_id_created_utc'
+ AND object_id = OBJECT_ID(N'[{schemaName}].[{ApiKeyAuditTable}]'))
+ CREATE INDEX ix_api_key_audit_key_id_created_utc
+ ON [{schemaName}].[{ApiKeyAuditTable}] (key_id, created_utc);
+ """;
+ }
+}
diff --git a/ZB.MOM.WW.Auth/tests/ZB.MOM.WW.Auth.ApiKeys.SqlServer.Tests/Live/LiveSqlServerFixture.cs b/ZB.MOM.WW.Auth/tests/ZB.MOM.WW.Auth.ApiKeys.SqlServer.Tests/Live/LiveSqlServerFixture.cs
new file mode 100644
index 0000000..83a1964
--- /dev/null
+++ b/ZB.MOM.WW.Auth/tests/ZB.MOM.WW.Auth.ApiKeys.SqlServer.Tests/Live/LiveSqlServerFixture.cs
@@ -0,0 +1,101 @@
+using Microsoft.Data.SqlClient;
+
+namespace ZB.MOM.WW.Auth.ApiKeys.SqlServer.Tests.Live;
+
+///
+/// Shared connection details for the env-gated live SQL Server suite.
+///
+///
+/// Gated on AUTH_SQLSERVER_CONNSTR and skips cleanly when it is unset, matching the family
+/// live-test idiom (see ZB.MOM.WW.Secrets.Replicator.SqlServer) — the offline suite must stay
+/// runnable on any machine. Set it to a database the test is allowed to create and drop a schema in,
+/// for example Server=localhost,1433;Database=ZbAuthTest;User Id=sa;Password=...;TrustServerCertificate=true.
+/// Each test class works in a throwaway schema rather than a throwaway database: it needs no
+/// CREATE DATABASE right, and a virgin schema still reproduces the CREATE SCHEMA race the migrator's
+/// retry loop exists for.
+///
+public static class LiveSqlServer
+{
+ /// Environment variable holding the live connection string.
+ public const string ConnectionStringVariable = "AUTH_SQLSERVER_CONNSTR";
+
+ /// The configured connection string, or when the suite is not enabled.
+ public static string? ConnectionString =>
+ Environment.GetEnvironmentVariable(ConnectionStringVariable);
+
+ /// Whether the live suite should run.
+ public static bool IsEnabled => !string.IsNullOrWhiteSpace(ConnectionString);
+
+ /// Skip reason shown when the suite is not enabled.
+ public const string SkipReason =
+ "Live SQL Server suite disabled; set AUTH_SQLSERVER_CONNSTR to enable.";
+
+ ///
+ /// Builds options against a throwaway schema so parallel runs and repeat runs cannot collide,
+ /// and so a failed run leaves no trace in a shared database.
+ ///
+ /// The unique schema name for this test.
+ /// Options targeting that schema.
+ public static SqlServerApiKeyOptions OptionsFor(string schemaName) => new()
+ {
+ ConnectionString = ConnectionString!,
+ Schema = schemaName,
+ };
+
+ /// Generates a unique, allow-list-legal schema name for one test.
+ /// A fresh schema name.
+ public static string NewSchemaName() => $"zbauth_test_{Guid.NewGuid():N}"[..32];
+
+ /// Drops the throwaway schema and its three tables.
+ /// The schema to drop.
+ /// A task that completes when the schema is gone.
+ public static async Task DropSchemaAsync(string schemaName)
+ {
+ if (!IsEnabled)
+ {
+ return;
+ }
+
+ await using var connection = new SqlConnection(ConnectionString);
+ await connection.OpenAsync();
+
+ await using SqlCommand command = connection.CreateCommand();
+ command.CommandText = $"""
+ IF OBJECT_ID(N'[{schemaName}].[api_key_audit]', N'U') IS NOT NULL DROP TABLE [{schemaName}].[api_key_audit];
+ IF OBJECT_ID(N'[{schemaName}].[api_keys]', N'U') IS NOT NULL DROP TABLE [{schemaName}].[api_keys];
+ IF OBJECT_ID(N'[{schemaName}].[schema_version]', N'U') IS NOT NULL DROP TABLE [{schemaName}].[schema_version];
+ IF EXISTS (SELECT 1 FROM sys.schemas WHERE name = N'{schemaName}') EXEC(N'DROP SCHEMA [{schemaName}]');
+ """;
+
+ await command.ExecuteNonQueryAsync();
+ }
+
+ /// Runs a scalar query against the live database.
+ /// The query text.
+ /// The first column of the first row, or .
+ public static async Task