diff --git a/ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.ApiKeys.SqlServer/SqliteApiKeyStoreImporter.cs b/ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.ApiKeys.SqlServer/SqliteApiKeyStoreImporter.cs
new file mode 100644
index 0000000..59c1ee8
--- /dev/null
+++ b/ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.ApiKeys.SqlServer/SqliteApiKeyStoreImporter.cs
@@ -0,0 +1,300 @@
+using System.Data;
+using Microsoft.Data.SqlClient;
+using Microsoft.Data.Sqlite;
+using ZB.MOM.WW.Auth.Abstractions.ApiKeys;
+using ZB.MOM.WW.Auth.ApiKeys.Sqlite;
+
+namespace ZB.MOM.WW.Auth.ApiKeys.SqlServer;
+
+/// Result of a one-shot import.
+/// Keys copied into SQL Server by this run.
+/// Keys whose key_id already existed in SQL Server.
+/// Audit rows copied into SQL Server by this run.
+/// Audit rows that already existed in SQL Server.
+/// Whether the SQLite file existed at all.
+public sealed record ApiKeyImportResult(
+ int KeysInserted, int KeysSkipped, int AuditInserted, int AuditSkipped, bool SourceFound);
+
+///
+/// Copies every key and audit row from a per-node SQLite store into the shared SQL Server store,
+/// insert-where-not-exists (keys by key_id; audit rows by key_id + event_type + created_utc), so it is
+/// idempotent and every node of a pair may run it: the result is the UNION of the nodes' files. A row
+/// that already exists in SQL Server is never modified — a revoke recorded on one node but not the
+/// other therefore survives only if the revoked copy is imported first; operators reconcile by listing.
+///
+/// Factory for connections to the shared SQL Server API-key database.
+public sealed class SqliteApiKeyStoreImporter(AuthSqlServerConnectionFactory sql)
+{
+ ///
+ /// Imports the keys and audit rows held in the SQLite store at .
+ ///
+ /// Path of the per-node SQLite API-key file.
+ /// A token to cancel the import.
+ ///
+ /// The per-category insert/skip counts. When the file does not exist the result is all zeroes
+ /// with and nothing is
+ /// opened — a host that has already imported (and renamed the file) pays nothing.
+ ///
+ ///
+ ///
+ /// The SQLite file is opened READ-ONLY and the schema migrator is deliberately NOT run against
+ /// it: the source is evidence, not something to upgrade. A file with no api_keys table is
+ /// treated as an empty source rather than an error, and a pre-v3 file (no expires_utc
+ /// column) reads its expiry as NULL = never expires, which is exactly what those keys meant.
+ ///
+ ///
+ /// Every row is read and the SQLite connection closed BEFORE any SQL Server write, and all the
+ /// writes then run inside ONE transaction. So the import is all-or-nothing: a failure part way
+ /// through rolls the transaction back and the exception propagates to the caller, leaving the
+ /// shared store exactly as it was. The host decides what that means — for a boot-time import,
+ /// logging and carrying on is usually right, because keys already in SQL Server keep working.
+ ///
+ ///
+ /// A SQL Server write failed; nothing was committed.
+ /// The source file could not be read.
+ public async Task ImportAsync(string sqlitePath, CancellationToken ct)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(sqlitePath);
+
+ if (!File.Exists(sqlitePath))
+ {
+ return new ApiKeyImportResult(0, 0, 0, 0, SourceFound: false);
+ }
+
+ (IReadOnlyList keys, IReadOnlyList auditEntries) =
+ await ReadSourceAsync(sqlitePath, ct).ConfigureAwait(false);
+
+ return await WriteAsync(keys, auditEntries, ct).ConfigureAwait(false);
+ }
+
+ // --- read side (SQLite) ---
+
+ private static async Task<(IReadOnlyList Keys, IReadOnlyList Audit)>
+ ReadSourceAsync(string sqlitePath, CancellationToken ct)
+ {
+ SqliteConnectionStringBuilder builder = new()
+ {
+ DataSource = sqlitePath,
+ Mode = SqliteOpenMode.ReadOnly,
+ };
+
+ await using var connection = new SqliteConnection(builder.ToString());
+ await connection.OpenAsync(ct).ConfigureAwait(false);
+
+ IReadOnlyList keys =
+ await TableExistsAsync(connection, SqliteAuthSchema.ApiKeysTable, ct).ConfigureAwait(false)
+ ? await ReadKeysAsync(connection, ct).ConfigureAwait(false)
+ : [];
+
+ IReadOnlyList audit =
+ await TableExistsAsync(connection, SqliteAuthSchema.ApiKeyAuditTable, ct).ConfigureAwait(false)
+ ? await ReadAuditAsync(connection, ct).ConfigureAwait(false)
+ : [];
+
+ return (keys, audit);
+ }
+
+ private static async Task TableExistsAsync(
+ SqliteConnection connection, string tableName, CancellationToken ct)
+ {
+ await using SqliteCommand command = connection.CreateCommand();
+ command.CommandText = """
+ SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = $table_name;
+ """;
+ command.Parameters.AddWithValue("$table_name", tableName);
+
+ return (long)(await command.ExecuteScalarAsync(ct).ConfigureAwait(false) ?? 0L) > 0;
+ }
+
+ private static async Task> ReadKeysAsync(
+ SqliteConnection connection, CancellationToken ct)
+ {
+ // A pre-v3 file has no expires_utc column, so select a literal NULL in its place rather than
+ // failing the import: NULL is precisely what those keys mean (never expires).
+ bool hasExpires = await ColumnExistsAsync(
+ connection, SqliteAuthSchema.ApiKeysTable, SqliteAuthSchema.ExpiresUtcColumn, ct).ConfigureAwait(false);
+
+ string expiresColumn = hasExpires ? SqliteAuthSchema.ExpiresUtcColumn : "NULL";
+
+ await using SqliteCommand command = connection.CreateCommand();
+ command.CommandText =
+ "SELECT key_id, key_prefix, secret_hash, display_name, scopes, constraints, " +
+ $"created_utc, last_used_utc, revoked_utc, {expiresColumn} FROM api_keys;";
+
+ List keys = [];
+
+ await using SqliteDataReader reader = await command.ExecuteReaderAsync(ct).ConfigureAwait(false);
+
+ while (await reader.ReadAsync(ct).ConfigureAwait(false))
+ {
+ keys.Add(new ApiKeyRecord(
+ KeyId: reader.GetString(0),
+ KeyPrefix: reader.GetString(1),
+ SecretHash: reader.GetFieldValue(2),
+ DisplayName: reader.GetString(3),
+ Scopes: ScopeSerializer.Deserialize(reader.GetString(4)),
+ ConstraintsJson: reader.IsDBNull(5) ? null : reader.GetString(5),
+ CreatedUtc: SqliteValueParsing.ParseUtc(reader.GetString(6)),
+ LastUsedUtc: SqliteValueParsing.ReadNullableUtc(reader, 7),
+ RevokedUtc: SqliteValueParsing.ReadNullableUtc(reader, 8),
+ ExpiresUtc: SqliteValueParsing.ReadNullableUtc(reader, 9)));
+ }
+
+ return keys;
+ }
+
+ // SQLite has no information_schema; PRAGMA table_info is the column probe (the same one
+ // SqliteAuthStoreMigrator uses to decide whether its v3 ALTER TABLE is needed).
+ private static async Task ColumnExistsAsync(
+ SqliteConnection connection, string tableName, string columnName, CancellationToken ct)
+ {
+ await using SqliteCommand command = connection.CreateCommand();
+ // table_info takes an identifier, not a bindable parameter; the name is an internal constant.
+ command.CommandText = $"PRAGMA table_info({tableName});";
+
+ await using SqliteDataReader reader = await command.ExecuteReaderAsync(ct).ConfigureAwait(false);
+
+ while (await reader.ReadAsync(ct).ConfigureAwait(false))
+ {
+ // PRAGMA table_info columns: cid(0), name(1), type(2), ...
+ if (string.Equals(reader.GetString(1), columnName, StringComparison.OrdinalIgnoreCase))
+ {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ private static async Task> ReadAuditAsync(
+ SqliteConnection connection, CancellationToken ct)
+ {
+ await using SqliteCommand command = connection.CreateCommand();
+ // Insertion order, so the shared table's IDENTITY re-creates the source's ordering.
+ command.CommandText = """
+ SELECT key_id, event_type, remote_address, created_utc, details
+ FROM api_key_audit
+ ORDER BY audit_id;
+ """;
+
+ List entries = [];
+
+ await using SqliteDataReader reader = await command.ExecuteReaderAsync(ct).ConfigureAwait(false);
+
+ while (await reader.ReadAsync(ct).ConfigureAwait(false))
+ {
+ entries.Add(new ApiKeyAuditEntry(
+ KeyId: reader.IsDBNull(0) ? null : reader.GetString(0),
+ EventType: reader.GetString(1),
+ RemoteAddress: reader.IsDBNull(2) ? null : reader.GetString(2),
+ CreatedUtc: SqliteValueParsing.ParseUtc(reader.GetString(3)),
+ Details: reader.IsDBNull(4) ? null : reader.GetString(4)));
+ }
+
+ return entries;
+ }
+
+ // --- write side (SQL Server) ---
+
+ private async Task WriteAsync(
+ IReadOnlyList keys,
+ IReadOnlyList auditEntries,
+ CancellationToken ct)
+ {
+ string schema = sql.Schema;
+
+ await using SqlConnection connection = await sql.OpenConnectionAsync(ct).ConfigureAwait(false);
+ await using SqlTransaction transaction =
+ (SqlTransaction)await connection.BeginTransactionAsync(ct).ConfigureAwait(false);
+
+ int keysInserted = 0;
+ int auditInserted = 0;
+
+ foreach (ApiKeyRecord key in keys)
+ {
+ if (await InsertKeyIfAbsentAsync(connection, transaction, schema, key, ct).ConfigureAwait(false))
+ {
+ keysInserted++;
+ }
+ }
+
+ foreach (ApiKeyAuditEntry entry in auditEntries)
+ {
+ if (await InsertAuditIfAbsentAsync(connection, transaction, schema, entry, ct).ConfigureAwait(false))
+ {
+ auditInserted++;
+ }
+ }
+
+ await transaction.CommitAsync(ct).ConfigureAwait(false);
+
+ return new ApiKeyImportResult(
+ KeysInserted: keysInserted,
+ KeysSkipped: keys.Count - keysInserted,
+ AuditInserted: auditInserted,
+ AuditSkipped: auditEntries.Count - auditInserted,
+ SourceFound: true);
+ }
+
+ // Returns true when this call inserted the row. The guard is inside the statement rather than a
+ // separate SELECT so the check and the insert cannot straddle a concurrent writer.
+ private async Task InsertKeyIfAbsentAsync(
+ SqlConnection connection,
+ SqlTransaction transaction,
+ string schema,
+ ApiKeyRecord key,
+ CancellationToken ct)
+ {
+ await using SqlCommand command = sql.CreateCommand(connection, $"""
+ IF NOT EXISTS (
+ SELECT 1 FROM [{schema}].[{SqlServerAuthSchema.ApiKeysTable}] WHERE key_id = @key_id)
+ INSERT INTO [{schema}].[{SqlServerAuthSchema.ApiKeysTable}] (
+ key_id, key_prefix, secret_hash, display_name, scopes,
+ constraints, created_utc, last_used_utc, revoked_utc, expires_utc)
+ VALUES (
+ @key_id, @key_prefix, @secret_hash, @display_name, @scopes,
+ @constraints, @created_utc, @last_used_utc, @revoked_utc, @expires_utc);
+ """, transaction);
+ command.Parameters.Add("@key_id", SqlDbType.NVarChar, 64).Value = key.KeyId;
+ command.Parameters.Add("@key_prefix", SqlDbType.NVarChar, 16).Value = key.KeyPrefix;
+ command.Parameters.Add("@secret_hash", SqlDbType.VarBinary, 64).Value = key.SecretHash;
+ command.Parameters.Add("@display_name", SqlDbType.NVarChar, 256).Value = key.DisplayName;
+ command.Parameters.Add("@scopes", SqlDbType.NVarChar, -1).Value = ScopeSerializer.Serialize(key.Scopes);
+ command.Parameters.Add("@constraints", SqlDbType.NVarChar, -1).Value = (object?)key.ConstraintsJson ?? DBNull.Value;
+ command.Parameters.Add("@created_utc", SqlDbType.DateTimeOffset).Value = key.CreatedUtc;
+ command.Parameters.Add("@last_used_utc", SqlDbType.DateTimeOffset).Value = (object?)key.LastUsedUtc ?? DBNull.Value;
+ command.Parameters.Add("@revoked_utc", SqlDbType.DateTimeOffset).Value = (object?)key.RevokedUtc ?? DBNull.Value;
+ command.Parameters.Add("@expires_utc", SqlDbType.DateTimeOffset).Value = (object?)key.ExpiresUtc ?? DBNull.Value;
+
+ return await command.ExecuteNonQueryAsync(ct).ConfigureAwait(false) > 0;
+ }
+
+ // Audit rows carry no natural key (audit_id is per-file IDENTITY), so identity is the triple
+ // key_id + event_type + created_utc. key_id is nullable, hence the explicit NULL-safe arm: a
+ // plain `key_id = @key_id` is UNKNOWN, never true, when both sides are NULL.
+ private async Task InsertAuditIfAbsentAsync(
+ SqlConnection connection,
+ SqlTransaction transaction,
+ string schema,
+ ApiKeyAuditEntry entry,
+ CancellationToken ct)
+ {
+ await using SqlCommand command = sql.CreateCommand(connection, $"""
+ IF NOT EXISTS (
+ SELECT 1 FROM [{schema}].[{SqlServerAuthSchema.ApiKeyAuditTable}]
+ WHERE ((key_id = @key_id) OR (key_id IS NULL AND @key_id IS NULL))
+ AND event_type = @event_type
+ AND created_utc = @created_utc)
+ INSERT INTO [{schema}].[{SqlServerAuthSchema.ApiKeyAuditTable}] (
+ key_id, event_type, remote_address, created_utc, details)
+ VALUES (@key_id, @event_type, @remote_address, @created_utc, @details);
+ """, transaction);
+ command.Parameters.Add("@key_id", SqlDbType.NVarChar, 64).Value = (object?)entry.KeyId ?? DBNull.Value;
+ command.Parameters.Add("@event_type", SqlDbType.NVarChar, 64).Value = entry.EventType;
+ command.Parameters.Add("@remote_address", SqlDbType.NVarChar, 64).Value = (object?)entry.RemoteAddress ?? DBNull.Value;
+ command.Parameters.Add("@created_utc", SqlDbType.DateTimeOffset).Value = entry.CreatedUtc;
+ command.Parameters.Add("@details", SqlDbType.NVarChar, -1).Value = (object?)entry.Details ?? DBNull.Value;
+
+ return await command.ExecuteNonQueryAsync(ct).ConfigureAwait(false) > 0;
+ }
+}
diff --git a/ZB.MOM.WW.Auth/tests/ZB.MOM.WW.Auth.ApiKeys.SqlServer.Tests/Live/SqliteApiKeyStoreImporterLiveTests.cs b/ZB.MOM.WW.Auth/tests/ZB.MOM.WW.Auth.ApiKeys.SqlServer.Tests/Live/SqliteApiKeyStoreImporterLiveTests.cs
new file mode 100644
index 0000000..3e6218f
--- /dev/null
+++ b/ZB.MOM.WW.Auth/tests/ZB.MOM.WW.Auth.ApiKeys.SqlServer.Tests/Live/SqliteApiKeyStoreImporterLiveTests.cs
@@ -0,0 +1,283 @@
+using Microsoft.Data.Sqlite;
+using ZB.MOM.WW.Auth.Abstractions.ApiKeys;
+using ZB.MOM.WW.Auth.ApiKeys.Sqlite;
+
+namespace ZB.MOM.WW.Auth.ApiKeys.SqlServer.Tests.Live;
+
+///
+/// Live tests for — the one-shot per-node SQLite to shared
+/// SQL Server copy.
+///
+///
+/// Each test owns a throwaway SQL Server schema AND a throwaway SQLite file under the temp
+/// directory; both are torn down in , so a failed run leaves nothing
+/// behind on a shared database or the developer's disk.
+///
+[Collection("live-sqlserver")]
+public sealed class SqliteApiKeyStoreImporterLiveTests : IAsyncLifetime
+{
+ private readonly string _schema = LiveSqlServer.NewSchemaName();
+ private readonly string _sqlitePath =
+ Path.Combine(Path.GetTempPath(), $"zbauth-import-{Guid.NewGuid():N}.db");
+
+ private AuthSqlServerConnectionFactory _factory = null!;
+ private SqlServerApiKeyAdminStore _sqlAdmin = null!;
+ private SqlServerApiKeyStore _sqlRead = null!;
+ private SqlServerApiKeyAuditStore _sqlAudit = null!;
+ private SqliteApiKeyStoreImporter _importer = null!;
+
+ ///
+ public async Task InitializeAsync()
+ {
+ if (!LiveSqlServer.IsEnabled)
+ {
+ return;
+ }
+
+ _factory = new AuthSqlServerConnectionFactory(LiveSqlServer.OptionsFor(_schema));
+ await new SqlServerApiKeyStoreMigrator(_factory).MigrateAsync(CancellationToken.None);
+ _sqlAdmin = new SqlServerApiKeyAdminStore(_factory);
+ _sqlRead = new SqlServerApiKeyStore(_factory);
+ _sqlAudit = new SqlServerApiKeyAuditStore(_factory);
+ _importer = new SqliteApiKeyStoreImporter(_factory);
+ }
+
+ ///
+ public async Task DisposeAsync()
+ {
+ // SQLite pools connections by connection string; the handle must be released before the
+ // file can be removed cleanly.
+ SqliteConnection.ClearAllPools();
+
+ foreach (string path in new[] { _sqlitePath, _sqlitePath + "-wal", _sqlitePath + "-shm" })
+ {
+ if (File.Exists(path))
+ {
+ File.Delete(path);
+ }
+ }
+
+ await LiveSqlServer.DropSchemaAsync(_schema);
+ }
+
+ [SkippableFact]
+ public async Task Import_CopiesKeysAndAuditRows_AndReturnsCounts()
+ {
+ Skip.IfNot(LiveSqlServer.IsEnabled, LiveSqlServer.SkipReason);
+
+ (ApiKeyRecord keyA, ApiKeyRecord keyB) = await SeedSqliteAsync();
+
+ ApiKeyImportResult result = await _importer.ImportAsync(_sqlitePath, CancellationToken.None);
+
+ Assert.True(result.SourceFound);
+ Assert.Equal(2, result.KeysInserted);
+ Assert.Equal(0, result.KeysSkipped);
+ Assert.Equal(3, result.AuditInserted);
+ Assert.Equal(0, result.AuditSkipped);
+
+ await AssertRecordMatchesAsync(keyA);
+ await AssertRecordMatchesAsync(keyB);
+
+ IReadOnlyList audit = await _sqlAudit.ListRecentAsync(10, CancellationToken.None);
+ Assert.Equal(3, audit.Count);
+ Assert.Contains(audit, e => e.KeyId is null && e.EventType == "auth_failed");
+ Assert.Contains(audit, e => e.KeyId == "key-a" && e.EventType == "created");
+ Assert.Contains(audit, e => e.KeyId == "key-b" && e.EventType == "created");
+ }
+
+ [SkippableFact]
+ public async Task Import_Twice_IsIdempotent()
+ {
+ Skip.IfNot(LiveSqlServer.IsEnabled, LiveSqlServer.SkipReason);
+
+ await SeedSqliteAsync();
+
+ await _importer.ImportAsync(_sqlitePath, CancellationToken.None);
+ ApiKeyImportResult second = await _importer.ImportAsync(_sqlitePath, CancellationToken.None);
+
+ Assert.True(second.SourceFound);
+ Assert.Equal(0, second.KeysInserted);
+ Assert.Equal(2, second.KeysSkipped);
+ Assert.Equal(0, second.AuditInserted);
+ Assert.Equal(3, second.AuditSkipped);
+
+ Assert.Equal(2, (await _sqlAdmin.ListAsync(CancellationToken.None)).Count);
+ Assert.Equal(3, (await _sqlAudit.ListRecentAsync(10, CancellationToken.None)).Count);
+ }
+
+ [SkippableFact]
+ public async Task Import_UnionsWithExistingRows()
+ {
+ Skip.IfNot(LiveSqlServer.IsEnabled, LiveSqlServer.SkipReason);
+
+ var lastUsed = new DateTimeOffset(2026, 7, 4, 12, 0, 0, TimeSpan.Zero);
+ ApiKeyRecord keyC = SampleRecord("key-c") with { LastUsedUtc = lastUsed };
+ await _sqlAdmin.CreateAsync(keyC, CancellationToken.None);
+
+ await SeedSqliteAsync();
+
+ ApiKeyImportResult result = await _importer.ImportAsync(_sqlitePath, CancellationToken.None);
+
+ Assert.Equal(2, result.KeysInserted);
+ Assert.Equal(0, result.KeysSkipped);
+
+ IReadOnlyList listed = await _sqlAdmin.ListAsync(CancellationToken.None);
+ Assert.Equal(
+ ["key-a", "key-b", "key-c"],
+ listed.Select(k => k.KeyId).OrderBy(k => k, StringComparer.Ordinal));
+
+ // The pre-existing row is untouched — the importer only ever inserts.
+ Assert.Equal(lastUsed, Assert.Single(listed, k => k.KeyId == "key-c").LastUsedUtc);
+ }
+
+ [SkippableFact]
+ public async Task Import_MissingFile_ReturnsZeroWithoutThrowing()
+ {
+ Skip.IfNot(LiveSqlServer.IsEnabled, LiveSqlServer.SkipReason);
+
+ ApiKeyImportResult result = await _importer.ImportAsync(
+ Path.Combine(Path.GetTempPath(), $"zbauth-absent-{Guid.NewGuid():N}.db"),
+ CancellationToken.None);
+
+ Assert.False(result.SourceFound);
+ Assert.Equal(0, result.KeysInserted);
+ Assert.Equal(0, result.KeysSkipped);
+ Assert.Equal(0, result.AuditInserted);
+ Assert.Equal(0, result.AuditSkipped);
+ }
+
+ [SkippableFact]
+ public async Task Import_PreV3FileWithoutExpiresColumn_ReadsExpiryAsNull()
+ {
+ Skip.IfNot(LiveSqlServer.IsEnabled, LiveSqlServer.SkipReason);
+
+ await CreatePreV3SqliteFileAsync();
+
+ ApiKeyImportResult result = await _importer.ImportAsync(_sqlitePath, CancellationToken.None);
+
+ Assert.True(result.SourceFound);
+ Assert.Equal(1, result.KeysInserted);
+
+ ApiKeyRecord? found = await _sqlRead.FindByKeyIdAsync("key-old", CancellationToken.None);
+ Assert.NotNull(found);
+ Assert.Null(found!.ExpiresUtc);
+ Assert.Equal("legacy key", found.DisplayName);
+ Assert.Equal(new DateTimeOffset(2026, 1, 2, 3, 4, 5, TimeSpan.Zero), found.CreatedUtc);
+ }
+
+ // --- helpers ---
+
+ ///
+ /// Seeds the throwaway SQLite file with two keys (one revoked, one with an expiry) and three
+ /// audit rows (one of them key-less), then returns the two key records as SQLite holds them.
+ ///
+ private async Task<(ApiKeyRecord KeyA, ApiKeyRecord KeyB)> SeedSqliteAsync()
+ {
+ var sqliteFactory = new AuthSqliteConnectionFactory(_sqlitePath);
+ await new SqliteAuthStoreMigrator(sqliteFactory).MigrateAsync(CancellationToken.None);
+
+ var admin = new SqliteApiKeyAdminStore(sqliteFactory);
+ var audit = new SqliteApiKeyAuditStore(sqliteFactory);
+ var read = new SqliteApiKeyStore(sqliteFactory);
+
+ ApiKeyRecord keyA = SampleRecord("key-a");
+ ApiKeyRecord keyB = SampleRecord("key-b") with
+ {
+ ExpiresUtc = new DateTimeOffset(2027, 3, 1, 0, 0, 0, TimeSpan.Zero),
+ };
+
+ await admin.CreateAsync(keyA, CancellationToken.None);
+ await admin.CreateAsync(keyB, CancellationToken.None);
+ await admin.RevokeAsync("key-a", new DateTimeOffset(2026, 6, 1, 7, 8, 9, TimeSpan.Zero), CancellationToken.None);
+
+ await audit.AppendAsync(
+ new ApiKeyAuditEntry("key-a", "created", "10.0.0.1", new DateTimeOffset(2026, 5, 1, 8, 30, 0, TimeSpan.Zero), "by admin"),
+ CancellationToken.None);
+ await audit.AppendAsync(
+ new ApiKeyAuditEntry("key-b", "created", null, new DateTimeOffset(2026, 5, 1, 8, 31, 0, TimeSpan.Zero), null),
+ CancellationToken.None);
+ await audit.AppendAsync(
+ new ApiKeyAuditEntry(null, "auth_failed", "10.0.0.9", new DateTimeOffset(2026, 5, 2, 9, 0, 0, TimeSpan.Zero), "unknown key"),
+ CancellationToken.None);
+
+ // Re-read so the assertions compare against exactly what SQLite holds, revocation included.
+ ApiKeyRecord storedA = (await read.FindByKeyIdAsync("key-a", CancellationToken.None))!;
+ ApiKeyRecord storedB = (await read.FindByKeyIdAsync("key-b", CancellationToken.None))!;
+
+ return (storedA, storedB);
+ }
+
+ ///
+ /// Writes a v2-shaped SQLite file by hand — api_keys WITHOUT expires_utc — and
+ /// inserts one key row, reproducing a store created before schema v3.
+ ///
+ private async Task CreatePreV3SqliteFileAsync()
+ {
+ await using var connection = new SqliteConnection(
+ new SqliteConnectionStringBuilder { DataSource = _sqlitePath }.ToString());
+ await connection.OpenAsync();
+
+ await using SqliteCommand ddl = connection.CreateCommand();
+ ddl.CommandText = """
+ CREATE TABLE api_keys (
+ key_id TEXT PRIMARY KEY,
+ key_prefix TEXT NOT NULL,
+ secret_hash BLOB NOT NULL,
+ display_name TEXT NOT NULL,
+ scopes TEXT NOT NULL,
+ constraints TEXT NULL,
+ created_utc TEXT NOT NULL,
+ last_used_utc TEXT NULL,
+ revoked_utc TEXT NULL
+ );
+ CREATE TABLE api_key_audit (
+ audit_id INTEGER PRIMARY KEY AUTOINCREMENT,
+ key_id TEXT NULL,
+ event_type TEXT NOT NULL,
+ remote_address TEXT NULL,
+ created_utc TEXT NOT NULL,
+ details TEXT NULL
+ );
+ """;
+ await ddl.ExecuteNonQueryAsync();
+
+ await using SqliteCommand insert = connection.CreateCommand();
+ insert.CommandText = """
+ INSERT INTO api_keys (
+ key_id, key_prefix, secret_hash, display_name, scopes,
+ constraints, created_utc, last_used_utc, revoked_utc)
+ VALUES ('key-old', 'sbk_old1', $hash, 'legacy key', 'read', NULL, $created, NULL, NULL);
+ """;
+ insert.Parameters.Add("$hash", SqliteType.Blob).Value = new byte[] { 4, 5, 6 };
+ insert.Parameters.AddWithValue(
+ "$created", new DateTimeOffset(2026, 1, 2, 3, 4, 5, TimeSpan.Zero).ToString("O"));
+ await insert.ExecuteNonQueryAsync();
+ }
+
+ private async Task AssertRecordMatchesAsync(ApiKeyRecord expected)
+ {
+ ApiKeyRecord? actual = await _sqlRead.FindByKeyIdAsync(expected.KeyId, CancellationToken.None);
+
+ Assert.NotNull(actual);
+ Assert.Equal(expected.KeyPrefix, actual!.KeyPrefix);
+ Assert.True(expected.SecretHash.SequenceEqual(actual.SecretHash));
+ Assert.Equal(expected.DisplayName, actual.DisplayName);
+ Assert.True(expected.Scopes.SetEquals(actual.Scopes));
+ Assert.Equal(expected.ConstraintsJson, actual.ConstraintsJson);
+ Assert.Equal(expected.CreatedUtc, actual.CreatedUtc);
+ Assert.Equal(expected.LastUsedUtc, actual.LastUsedUtc);
+ Assert.Equal(expected.RevokedUtc, actual.RevokedUtc);
+ Assert.Equal(expected.ExpiresUtc, actual.ExpiresUtc);
+ }
+
+ private static ApiKeyRecord SampleRecord(string keyId) => new(
+ KeyId: keyId,
+ KeyPrefix: "sbk_ab12",
+ SecretHash: [1, 2, 3, 4, 5, 6, 7, 8],
+ DisplayName: "Test Key " + keyId,
+ Scopes: new HashSet(["read", "write"], StringComparer.Ordinal),
+ ConstraintsJson: """{"ipAllow":["10.0.0.0/8"]}""",
+ CreatedUtc: new DateTimeOffset(2026, 5, 1, 8, 30, 0, TimeSpan.Zero),
+ LastUsedUtc: null,
+ RevokedUtc: null);
+}