From 2e2d649f6faba0b35b50d167480e42e2f1a4abbe Mon Sep 17 00:00:00 2001 From: cesnimda Date: Sun, 30 Aug 2026 16:52:07 +0200 Subject: [PATCH] refactor(db): migrate auth support tables Move recovery codes, trusted devices, and revocable sessions into an additive provider-aware migration. Preserve existing security records and retain guarded MariaDB repairs for historical schemas. --- JobTrackerApi.Tests/MigrationChainTests.cs | 80 ++++++++++++++ ...123000_AdoptAuthenticationSupportSchema.cs | 102 ++++++++++++++++++ .../StartupInitializationExtensions.cs | 99 ----------------- .../Services/StartupSchemaOwnership.cs | 6 +- docs/audits/audit-remediation-backlog.md | 2 +- docs/audits/verification-log.md | 1 + docs/infrastructure/database-ownership.md | 14 ++- docs/verification/jt-019-schema-ownership.md | 15 ++- docs/work-programmes/master-progress.md | 3 +- 9 files changed, 209 insertions(+), 113 deletions(-) create mode 100644 JobTrackerApi/Migrations/20260830123000_AdoptAuthenticationSupportSchema.cs diff --git a/JobTrackerApi.Tests/MigrationChainTests.cs b/JobTrackerApi.Tests/MigrationChainTests.cs index 335420a..b7508a6 100644 --- a/JobTrackerApi.Tests/MigrationChainTests.cs +++ b/JobTrackerApi.Tests/MigrationChainTests.cs @@ -102,6 +102,9 @@ public sealed class MigrationChainTests Assert.Contains("CREATE TABLE IF NOT EXISTS `SystemEmailSettings`", script, StringComparison.Ordinal); Assert.Contains("CREATE TABLE IF NOT EXISTS `UserRuleSettings`", script, StringComparison.Ordinal); Assert.Contains("CREATE TABLE IF NOT EXISTS `GmailReviewDecisions`", script, StringComparison.Ordinal); + Assert.Contains("CREATE TABLE IF NOT EXISTS `TwoFactorRecoveryCodes`", script, StringComparison.Ordinal); + Assert.Contains("CREATE TABLE IF NOT EXISTS `TrustedDevices`", script, StringComparison.Ordinal); + Assert.Contains("CREATE TABLE IF NOT EXISTS `UserSessions`", script, StringComparison.Ordinal); Assert.Contains("`UiLanguage` varchar(16)", script, StringComparison.Ordinal); Assert.All( Regex.Matches(script, "CONSTRAINT `([^`]+)`").Select(match => match.Groups[1].Value), @@ -223,6 +226,83 @@ public sealed class MigrationChainTests Assert.Empty(await db.Database.GetPendingMigrationsAsync()); } + [Fact] + public async Task Authentication_support_adoption_preserves_legacy_rows() + { + await using var connection = new SqliteConnection("Data Source=:memory:"); + await connection.OpenAsync(); + await using var db = Context(connection); + var migrator = db.GetService(); + await migrator.MigrateAsync("20260830122000_AdoptGmailReviewDecisionsSchema"); + await ExecuteAsync(connection, """ + CREATE TABLE "TwoFactorRecoveryCodes" ( + "Id" INTEGER NOT NULL CONSTRAINT "PK_TwoFactorRecoveryCodes" PRIMARY KEY AUTOINCREMENT, + "UserId" TEXT NOT NULL, + "CodeHash" TEXT NOT NULL, + "CreatedAtUtc" TEXT NOT NULL, + "UsedAtUtc" TEXT NULL + ); + INSERT INTO "TwoFactorRecoveryCodes" ("UserId", "CodeHash", "CreatedAtUtc") + VALUES ('owner-fixture', 'recovery-hash', '2026-08-30T09:00:00+00:00'); + + CREATE TABLE "TrustedDevices" ( + "Id" INTEGER NOT NULL CONSTRAINT "PK_TrustedDevices" PRIMARY KEY AUTOINCREMENT, + "UserId" TEXT NOT NULL, + "TokenHash" TEXT NOT NULL, + "DeviceLabel" TEXT NULL, + "CreatedAtUtc" TEXT NOT NULL, + "LastSeenAtUtc" TEXT NOT NULL, + "ExpiresAtUtc" TEXT NOT NULL + ); + INSERT INTO "TrustedDevices" + ("UserId", "TokenHash", "DeviceLabel", "CreatedAtUtc", "LastSeenAtUtc", "ExpiresAtUtc") + VALUES + ('owner-fixture', 'device-hash', 'Fixture browser', '2026-08-30T09:00:00+00:00', + '2026-08-30T09:30:00+00:00', '2026-09-29T09:00:00+00:00'); + + CREATE TABLE "UserSessions" ( + "Id" TEXT NOT NULL CONSTRAINT "PK_UserSessions" PRIMARY KEY, + "UserId" TEXT NOT NULL, + "DeviceLabel" TEXT NULL, + "CreatedAtUtc" TEXT NOT NULL, + "LastSeenAtUtc" TEXT NOT NULL, + "ExpiresAtUtc" TEXT NOT NULL, + "RevokedAtUtc" TEXT NULL + ); + INSERT INTO "UserSessions" + ("Id", "UserId", "DeviceLabel", "CreatedAtUtc", "LastSeenAtUtc", "ExpiresAtUtc") + VALUES + ('session-fixture', 'owner-fixture', 'Fixture session', '2026-08-30T09:00:00+00:00', + '2026-08-30T09:30:00+00:00', '2026-08-31T09:00:00+00:00'); + """); + + await migrator.MigrateAsync(); + Assert.Equal("recovery-hash", await ScalarAsync(connection, + "SELECT CodeHash FROM TwoFactorRecoveryCodes WHERE UserId = 'owner-fixture';")); + Assert.Equal("Fixture browser", await ScalarAsync(connection, + "SELECT DeviceLabel FROM TrustedDevices WHERE TokenHash = 'device-hash';")); + Assert.Equal("Fixture session", await ScalarAsync(connection, + "SELECT DeviceLabel FROM UserSessions WHERE Id = 'session-fixture';")); + + await migrator.MigrateAsync("20260830122000_AdoptGmailReviewDecisionsSchema"); + Assert.Equal("recovery-hash", await ScalarAsync(connection, + "SELECT CodeHash FROM TwoFactorRecoveryCodes WHERE UserId = 'owner-fixture';")); + Assert.Equal("device-hash", await ScalarAsync(connection, + "SELECT TokenHash FROM TrustedDevices WHERE UserId = 'owner-fixture';")); + Assert.Equal("session-fixture", await ScalarAsync(connection, + "SELECT Id FROM UserSessions WHERE UserId = 'owner-fixture';")); + + await migrator.MigrateAsync(); + Assert.Equal(3L, await ScalarAsync(connection, """ + SELECT COUNT(*) FROM sqlite_master + WHERE type = 'index' AND name IN ( + 'IX_TwoFactorRecoveryCodes_UserId_UsedAtUtc', + 'IX_TrustedDevices_UserId', + 'IX_UserSessions_UserId'); + """)); + Assert.Empty(await db.Database.GetPendingMigrationsAsync()); + } + private static JobTrackerContext Context(SqliteConnection connection) { var currentUser = new Mock(); diff --git a/JobTrackerApi/Migrations/20260830123000_AdoptAuthenticationSupportSchema.cs b/JobTrackerApi/Migrations/20260830123000_AdoptAuthenticationSupportSchema.cs new file mode 100644 index 0000000..d37adc3 --- /dev/null +++ b/JobTrackerApi/Migrations/20260830123000_AdoptAuthenticationSupportSchema.cs @@ -0,0 +1,102 @@ +using System; +using JobTrackerApi.Data; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace JobTrackerApi.Migrations; + +/// +/// Moves the authentication support tables from startup reconciliation into the migration chain +/// without replacing tables or rows that pre-date migration ownership. +/// +[DbContext(typeof(JobTrackerContext))] +[Migration("20260830123000_AdoptAuthenticationSupportSchema")] +public sealed class AdoptAuthenticationSupportSchema : Migration +{ + protected override void Up(MigrationBuilder migrationBuilder) + { + if (ActiveProvider.Contains("MySql", StringComparison.OrdinalIgnoreCase)) + { + migrationBuilder.Sql(""" + CREATE TABLE IF NOT EXISTS `TwoFactorRecoveryCodes` ( + `Id` int NOT NULL AUTO_INCREMENT, + `UserId` varchar(255) NOT NULL, + `CodeHash` varchar(255) NOT NULL, + `CreatedAtUtc` datetime(6) NOT NULL, + `UsedAtUtc` datetime(6) NULL, + PRIMARY KEY (`Id`) + ) CHARACTER SET=utf8mb4; + CREATE INDEX IF NOT EXISTS `IX_TwoFactorRecoveryCodes_UserId_UsedAtUtc` + ON `TwoFactorRecoveryCodes` (`UserId`, `UsedAtUtc`); + + CREATE TABLE IF NOT EXISTS `TrustedDevices` ( + `Id` int NOT NULL AUTO_INCREMENT, + `UserId` varchar(255) NOT NULL, + `TokenHash` varchar(255) NOT NULL, + `DeviceLabel` varchar(255) NULL, + `CreatedAtUtc` datetime(6) NOT NULL, + `LastSeenAtUtc` datetime(6) NOT NULL, + `ExpiresAtUtc` datetime(6) NOT NULL, + PRIMARY KEY (`Id`) + ) CHARACTER SET=utf8mb4; + CREATE INDEX IF NOT EXISTS `IX_TrustedDevices_UserId` ON `TrustedDevices` (`UserId`); + CREATE INDEX IF NOT EXISTS `IX_TrustedDevices_TokenHash` ON `TrustedDevices` (`TokenHash`); + + CREATE TABLE IF NOT EXISTS `UserSessions` ( + `Id` varchar(64) NOT NULL, + `UserId` varchar(255) NOT NULL, + `DeviceLabel` varchar(255) NULL, + `CreatedAtUtc` datetime(6) NOT NULL, + `LastSeenAtUtc` datetime(6) NOT NULL, + `ExpiresAtUtc` datetime(6) NOT NULL, + `RevokedAtUtc` datetime(6) NULL, + PRIMARY KEY (`Id`) + ) CHARACTER SET=utf8mb4; + CREATE INDEX IF NOT EXISTS `IX_UserSessions_UserId` ON `UserSessions` (`UserId`); + """); + return; + } + + migrationBuilder.Sql(""" + CREATE TABLE IF NOT EXISTS "TwoFactorRecoveryCodes" ( + "Id" INTEGER NOT NULL CONSTRAINT "PK_TwoFactorRecoveryCodes" PRIMARY KEY AUTOINCREMENT, + "UserId" TEXT NOT NULL, + "CodeHash" TEXT NOT NULL, + "CreatedAtUtc" TEXT NOT NULL, + "UsedAtUtc" TEXT NULL + ); + CREATE INDEX IF NOT EXISTS "IX_TwoFactorRecoveryCodes_UserId_UsedAtUtc" + ON "TwoFactorRecoveryCodes" ("UserId", "UsedAtUtc"); + + CREATE TABLE IF NOT EXISTS "TrustedDevices" ( + "Id" INTEGER NOT NULL CONSTRAINT "PK_TrustedDevices" PRIMARY KEY AUTOINCREMENT, + "UserId" TEXT NOT NULL, + "TokenHash" TEXT NOT NULL, + "DeviceLabel" TEXT NULL, + "CreatedAtUtc" TEXT NOT NULL, + "LastSeenAtUtc" TEXT NOT NULL, + "ExpiresAtUtc" TEXT NOT NULL + ); + CREATE INDEX IF NOT EXISTS "IX_TrustedDevices_UserId" ON "TrustedDevices" ("UserId"); + CREATE INDEX IF NOT EXISTS "IX_TrustedDevices_TokenHash" ON "TrustedDevices" ("TokenHash"); + + CREATE TABLE IF NOT EXISTS "UserSessions" ( + "Id" TEXT NOT NULL CONSTRAINT "PK_UserSessions" PRIMARY KEY, + "UserId" TEXT NOT NULL, + "DeviceLabel" TEXT NULL, + "CreatedAtUtc" TEXT NOT NULL, + "LastSeenAtUtc" TEXT NOT NULL, + "ExpiresAtUtc" TEXT NOT NULL, + "RevokedAtUtc" TEXT NULL + ); + CREATE INDEX IF NOT EXISTS "IX_UserSessions_UserId" ON "UserSessions" ("UserId"); + """); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + // Preserve security records that may have been created before migration ownership. + } +} diff --git a/JobTrackerApi/Services/StartupInitializationExtensions.cs b/JobTrackerApi/Services/StartupInitializationExtensions.cs index b7c3b8a..c41deb1 100644 --- a/JobTrackerApi/Services/StartupInitializationExtensions.cs +++ b/JobTrackerApi/Services/StartupInitializationExtensions.cs @@ -687,56 +687,6 @@ public static class StartupInitializationExtensions Exec(c, """CREATE INDEX IF NOT EXISTS "IX_TailoredCvDrafts_JobApplicationId" ON "TailoredCvDrafts" ("JobApplicationId");"""); } - static void EnsureTwoFactorRecoveryCodesTable(DbConnection c) - { - Exec(c, """ - CREATE TABLE IF NOT EXISTS "TwoFactorRecoveryCodes" ( - "Id" INTEGER NOT NULL CONSTRAINT "PK_TwoFactorRecoveryCodes" PRIMARY KEY AUTOINCREMENT, - "UserId" TEXT NOT NULL, - "CodeHash" TEXT NOT NULL, - "CreatedAtUtc" TEXT NOT NULL, - "UsedAtUtc" TEXT NULL - ); - """); - - Exec(c, """CREATE INDEX IF NOT EXISTS "IX_TwoFactorRecoveryCodes_UserId_UsedAtUtc" ON "TwoFactorRecoveryCodes" ("UserId", "UsedAtUtc");"""); - } - - static void EnsureTrustedDevicesTable(DbConnection c) - { - Exec(c, """ - CREATE TABLE IF NOT EXISTS "TrustedDevices" ( - "Id" INTEGER NOT NULL CONSTRAINT "PK_TrustedDevices" PRIMARY KEY AUTOINCREMENT, - "UserId" TEXT NOT NULL, - "TokenHash" TEXT NOT NULL, - "DeviceLabel" TEXT NULL, - "CreatedAtUtc" TEXT NOT NULL, - "LastSeenAtUtc" TEXT NOT NULL, - "ExpiresAtUtc" TEXT NOT NULL - ); - """); - - Exec(c, """CREATE INDEX IF NOT EXISTS "IX_TrustedDevices_UserId" ON "TrustedDevices" ("UserId");"""); - Exec(c, """CREATE INDEX IF NOT EXISTS "IX_TrustedDevices_TokenHash" ON "TrustedDevices" ("TokenHash");"""); - } - - static void EnsureUserSessionsTable(DbConnection c) - { - Exec(c, """ - CREATE TABLE IF NOT EXISTS "UserSessions" ( - "Id" TEXT NOT NULL CONSTRAINT "PK_UserSessions" PRIMARY KEY, - "UserId" TEXT NOT NULL, - "DeviceLabel" TEXT NULL, - "CreatedAtUtc" TEXT NOT NULL, - "LastSeenAtUtc" TEXT NOT NULL, - "ExpiresAtUtc" TEXT NOT NULL, - "RevokedAtUtc" TEXT NULL - ); - """); - - Exec(c, """CREATE INDEX IF NOT EXISTS "IX_UserSessions_UserId" ON "UserSessions" ("UserId");"""); - } - // Career Workspace foundation (docs/career-workspace-implementation-roadmap.md // Phase F1). Additive tables: ApplicationUser.ProfileCvStructureJson remains the // authoritative column every existing read path uses; these mirror it so future @@ -1078,9 +1028,6 @@ public static class StartupInitializationExtensions EnsureMicrosoftGraphConnectionsTable(conn); EnsureImapConnectionsTable(conn); EnsureCvTables(conn); - EnsureTwoFactorRecoveryCodesTable(conn); - EnsureTrustedDevicesTable(conn); - EnsureUserSessionsTable(conn); EnsureCareerProfileTables(conn); EnsureInterviewPrepNotesTable(conn); EnsureAiWorkspaceNotesTable(conn); @@ -1820,62 +1767,16 @@ public static class StartupInitializationExtensions EnsureMySqlIndex(conn, "CareerProfileVersions", "IX_CareerProfileVersions_OwnerUserId_CareerProfileId_Version", "`OwnerUserId`, `CareerProfileId`, `Version`"); - if (!HasMySqlTable(conn, "TwoFactorRecoveryCodes")) - { - using var cmd = conn.CreateCommand(); - cmd.CommandText = @"CREATE TABLE IF NOT EXISTS `TwoFactorRecoveryCodes` ( - `Id` int NOT NULL AUTO_INCREMENT, - `UserId` varchar(255) NOT NULL, - `CodeHash` varchar(255) NOT NULL, - `CreatedAtUtc` datetime(6) NOT NULL, - `UsedAtUtc` datetime(6) NULL, - PRIMARY KEY (`Id`) - );"; - cmd.ExecuteNonQuery(); - } - EnsureMySqlAutoIncrementPrimaryKey(conn, "TwoFactorRecoveryCodes", "Id"); EnsureMySqlIndex(conn, "TwoFactorRecoveryCodes", "IX_TwoFactorRecoveryCodes_UserId_UsedAtUtc", "`UserId`, `UsedAtUtc`"); - if (!HasMySqlTable(conn, "TrustedDevices")) - { - using var cmd = conn.CreateCommand(); - cmd.CommandText = @"CREATE TABLE IF NOT EXISTS `TrustedDevices` ( - `Id` int NOT NULL AUTO_INCREMENT, - `UserId` varchar(255) NOT NULL, - `TokenHash` varchar(255) NOT NULL, - `DeviceLabel` varchar(255) NULL, - `CreatedAtUtc` datetime(6) NOT NULL, - `LastSeenAtUtc` datetime(6) NOT NULL, - `ExpiresAtUtc` datetime(6) NOT NULL, - PRIMARY KEY (`Id`) - );"; - cmd.ExecuteNonQuery(); - } - EnsureMySqlAutoIncrementPrimaryKey(conn, "TrustedDevices", "Id"); EnsureMySqlIndex(conn, "TrustedDevices", "IX_TrustedDevices_UserId", "`UserId`"); EnsureMySqlIndex(conn, "TrustedDevices", "IX_TrustedDevices_TokenHash", "`TokenHash`"); - if (!HasMySqlTable(conn, "UserSessions")) - { - using var cmd = conn.CreateCommand(); - cmd.CommandText = @"CREATE TABLE IF NOT EXISTS `UserSessions` ( - `Id` varchar(64) NOT NULL, - `UserId` varchar(255) NOT NULL, - `DeviceLabel` varchar(255) NULL, - `CreatedAtUtc` datetime(6) NOT NULL, - `LastSeenAtUtc` datetime(6) NOT NULL, - `ExpiresAtUtc` datetime(6) NOT NULL, - `RevokedAtUtc` datetime(6) NULL, - PRIMARY KEY (`Id`) - );"; - cmd.ExecuteNonQuery(); - } - EnsureMySqlIndex(conn, "UserSessions", "IX_UserSessions_UserId", "`UserId`"); // Schema reconciliation must never crash app startup: an index that fails diff --git a/JobTrackerApi/Services/StartupSchemaOwnership.cs b/JobTrackerApi/Services/StartupSchemaOwnership.cs index 3b0a2e0..94a373c 100644 --- a/JobTrackerApi/Services/StartupSchemaOwnership.cs +++ b/JobTrackerApi/Services/StartupSchemaOwnership.cs @@ -23,9 +23,12 @@ internal static class StartupSchemaOwnership "Jobs", "RuleSettings", "SystemEmailSettings", + "TrustedDevices", + "TwoFactorRecoveryCodes", "UserNotifications", "UserOperations", "UserRuleSettings", + "UserSessions", }; internal static readonly IReadOnlySet ReconcilerOwnedTables = new HashSet(StringComparer.Ordinal) @@ -59,9 +62,6 @@ internal static class StartupSchemaOwnership "InterviewPrepNotes", "MicrosoftGraphConnections", "TailoredCvDrafts", - "TrustedDevices", - "TwoFactorRecoveryCodes", - "UserSessions", }; // Historical migrations contain guarded compatibility bootstraps for these tables so direct diff --git a/docs/audits/audit-remediation-backlog.md b/docs/audits/audit-remediation-backlog.md index 587594b..aedc0a8 100644 --- a/docs/audits/audit-remediation-backlog.md +++ b/docs/audits/audit-remediation-backlog.md @@ -558,7 +558,7 @@ SEC-008 implements the same durable state machine with `.uploading` and ` ### P3-1 — Reduce dual schema ownership incrementally -**Status (2026-08-30): in progress.** All 49 model tables now have an executable, disjoint creation-owner classification. `SystemEmailSettings`, `UserRuleSettings`, and `GmailReviewDecisions` have moved through additive provider-aware migrations with legacy-row preservation, downgrade/retry safety, startup-DDL removal, fresh SQLite runtime proof and generated MariaDB SQL. The Gmail decision migration also closes its missing MariaDB creation path. Thirty-two startup-created tables remain; see `docs/verification/jt-019-schema-ownership.md` and V-194–V-196. +**Status (2026-08-30): in progress.** All 49 model tables now have an executable, disjoint creation-owner classification. `SystemEmailSettings`, `UserRuleSettings`, `GmailReviewDecisions`, `TwoFactorRecoveryCodes`, `TrustedDevices`, and `UserSessions` have moved through additive provider-aware migrations with legacy-row preservation, downgrade/retry safety, startup-DDL removal, fresh SQLite runtime proof and generated MariaDB SQL. The Gmail decision migration also closes its missing MariaDB creation path. Twenty-nine startup-created tables remain; see `docs/verification/jt-019-schema-ownership.md` and V-194–V-197. - **Findings/scope:** JT-019; inventory reconciler operations and move stable schema ownership to EF migrations in small steps. - **Dependencies:** provider upgrade fixtures and P2-2 restore safety. diff --git a/docs/audits/verification-log.md b/docs/audits/verification-log.md index e07dd09..6a7e060 100644 --- a/docs/audits/verification-log.md +++ b/docs/audits/verification-log.md @@ -228,3 +228,4 @@ Output was reduced to filenames and commit counts. The token artifact appears un | V-194 | Executable 49-table ownership partition; focused ownership/migration chain; legacy-row upgrade; MariaDB script; full backend; fresh application SQLite startup | Repository root / disposable local SQLite | Establish one creation owner per model table and transfer the independent SystemEmailSettings table from startup DDL to EF migration ownership | PASS — ownership/migration 6/6; blank and repeated migration succeed; representative legacy SMTP row survives; provider SQL is present; backend 722/722; fresh runtime applies `20260830120000_AdoptSystemEmailSettingsSchema` and starts healthy | MariaDB SQL is generated but the new migration was not executed on a live MariaDB server; downgrade deliberately preserves ambiguous pre-migration data. Thirty-four reconciler-owned tables remain | JT-019 first migration-backed ownership transfer complete; provider runtime and remaining dependency groups continue incrementally | | V-195 | Ownership/migration chain; populated adoption/downgrade/retry; MariaDB script; full backend | Repository root / disposable local SQLite | Transfer the independent UserRuleSettings table from both startup-DDL paths to migration ownership without losing per-user settings | PASS — focused ownership/migration 7/7; representative owner row survives adoption, downgrade and re-upgrade; generated MariaDB SQL contains provider-safe DDL; startup no longer contains the table create; full backend 723/723 | MariaDB SQL generated only; no production migration. Down preserves ambiguous legacy data by design. Thirty-three reconciler-owned tables remain | JT-019 second leaf transfer complete; dependency groups continue incrementally | | V-196 | Ownership/migration chain; populated adoption/downgrade/retry; MariaDB script; full backend | Repository root / disposable local SQLite | Move GmailReviewDecisions to migration ownership and close the missing MariaDB table path | PASS — focused ownership/migration 8/8; representative decision survives adoption, downgrade and re-upgrade; generated MariaDB SQL contains provider-safe DDL; SQLite startup create is removed; full backend 724/724 | MariaDB SQL generated only; no provider account or production migration. Down preserves ambiguous legacy data by design. Thirty-two reconciler-owned tables remain | JT-019 third leaf transfer complete; provider runtime and dependency groups continue incrementally | +| V-197 | Ownership/migration chain; populated adoption/downgrade/retry; index assertions; MariaDB script; full backend | Repository root / disposable local SQLite | Move the authentication-support group to migration ownership without invalidating recovery codes, trusted devices, or active sessions | PASS — focused ownership/migration 9/9; representative rows survive adoption, downgrade and re-upgrade; expected indexes exist; generated MariaDB SQL contains all three provider-safe definitions; both startup-create paths are removed; full backend 725/725 | MariaDB SQL generated only; no provider account or production migration. Guarded MariaDB index/auto-increment repairs remain temporarily. Twenty-nine reconciler-owned tables remain | JT-019 authentication-support transfer complete; dependent feature groups continue incrementally | diff --git a/docs/infrastructure/database-ownership.md b/docs/infrastructure/database-ownership.md index 7d6a8e6..27e9999 100644 --- a/docs/infrastructure/database-ownership.md +++ b/docs/infrastructure/database-ownership.md @@ -67,8 +67,10 @@ already-correct database. Two consequences worth knowing: Created by EF migrations, never by the reconciler: `AccountDeletionFiles`, `AccountDeletionRequests`, `AiUsageRecords`, `Attachments`, `Companies`, -`Correspondences`, `EmailDrafts`, `EmailSendAttempts`, `GmailReviewDecisions`, `JobApplications`, `JobEvents`, `Jobs`, -`RuleSettings`, `SystemEmailSettings`, `UserNotifications`, `UserOperations`, and `UserRuleSettings`. +`Correspondences`, `EmailDrafts`, `EmailSendAttempts`, `GmailReviewDecisions`, `JobApplications`, +`JobEvents`, `Jobs`, `RuleSettings`, `SystemEmailSettings`, `TrustedDevices`, +`TwoFactorRecoveryCodes`, `UserNotifications`, `UserOperations`, `UserRuleSettings`, and +`UserSessions`. `SystemEmailSettings` is the first completed ownership transfer: migration `20260830120000_AdoptSystemEmailSettingsSchema` creates it for both supported providers and preserves @@ -80,6 +82,11 @@ preserved across adoption, downgrade and retry, and startup no longer creates it `GmailReviewDecisions` moved in `20260830122000_AdoptGmailReviewDecisionsSchema`. This also supplies the table on MariaDB, where the old reconciler had no creation path. +The authentication support group (`TwoFactorRecoveryCodes`, `TrustedDevices`, and `UserSessions`) +moved in `20260830123000_AdoptAuthenticationSupportSchema`. These tables deliberately have no +database foreign key to `AspNetUsers` because they are queried during authentication before a +current-user scope exists. + The reconciler may **repair** these (add a missing column, add an index, fix a non-`AUTO_INCREMENT` primary key) and may seed the default `RuleSettings` row — but it must never `CREATE TABLE` them. It used to create `RuleSettings`, which is precisely why a clean install failed with @@ -94,8 +101,7 @@ Created by `StartupInitializationExtensions`, with a **no-op migration** holding `CareerProfiles`, `CareerProfileVersions`, the six CareerProfile children (`CareerExperiences`, `CareerEducations`, `CareerSkills`, `CareerProjects`, `CareerCertifications`, `CareerLanguages`), `InterviewPrepNotes`, `AiWorkspaceNotes`, `CvVariants`, `CvVariantVersions`, `AiInteractions`, -`ApplicationChecklistItems`, `CoverLetterVersions`, `InterviewPrepItems`, `TwoFactorRecoveryCodes`, -`TrustedDevices`, `UserSessions`. +`ApplicationChecklistItems`, `CoverLetterVersions`, and `InterviewPrepItems`. The seven ASP.NET Identity tables are also currently reconciler-owned, despite older wording that called them migration-owned: `AspNetRoles`, `AspNetUsers`, `AspNetRoleClaims`, `AspNetUserClaims`, diff --git a/docs/verification/jt-019-schema-ownership.md b/docs/verification/jt-019-schema-ownership.md index 77239a2..fef8cfa 100644 --- a/docs/verification/jt-019-schema-ownership.md +++ b/docs/verification/jt-019-schema-ownership.md @@ -1,9 +1,9 @@ -# JT-019 schema ownership — first migration-backed transfer +# JT-019 schema ownership — incremental migration transfers ## Scope -This increment establishes a behaviour-preserving ownership boundary and moves one independent -table. It does not attempt to delete the legacy reconciler wholesale. +This work establishes a behaviour-preserving ownership boundary and moves small independent or +cohesive table groups. It does not attempt to delete the legacy reconciler wholesale. ## Implemented @@ -19,6 +19,9 @@ table. It does not attempt to delete the legacy reconciler wholesale. for the independent per-user rule-settings table. - Added `20260830122000_AdoptGmailReviewDecisionsSchema`, removed the SQLite startup-create path, and supplied the previously absent MariaDB table definition. +- Added `20260830123000_AdoptAuthenticationSupportSchema` for recovery codes, trusted devices, and + revocable user sessions; both provider startup-create paths are removed while guarded MariaDB + repair checks remain for historical schemas. - Corrected the ownership runbook: new tables now default to migrations; the reconciler is legacy compatibility code to retire one dependency group at a time. @@ -38,14 +41,16 @@ migration. settings row, upgrades without changing that row. - A representative per-user rules row survives adoption, downgrade, and re-upgrade. - A representative Gmail review decision survives adoption, downgrade, and re-upgrade. +- Representative recovery-code, trusted-device, and user-session rows survive adoption, downgrade, + and re-upgrade, and their indexes are present afterwards. - Generated MariaDB SQL contains the provider-correct `SystemEmailSettings` DDL. -- Full backend: 724/724 passed after all three leaf-table transfers. +- Full backend: 725/725 passed after the authentication-support transfer. - Fresh application startup over a new disposable SQLite database applied `20260830120000_AdoptSystemEmailSettingsSchema` and reached the healthy listening state. ## Remaining JT-019 work -Thirty-two model tables remain startup-created, including the Identity group and several tables +Twenty-nine model tables remain startup-created, including the Identity group and several tables with parent dependencies. Transfer them in small dependency-aware migrations with blank, populated, retry and MariaDB runtime proof. Column/index repairs must remain until historical upgrade fixtures prove each one redundant. diff --git a/docs/work-programmes/master-progress.md b/docs/work-programmes/master-progress.md index 5d83dae..858e769 100644 --- a/docs/work-programmes/master-progress.md +++ b/docs/work-programmes/master-progress.md @@ -46,6 +46,7 @@ Updated: 2026-08-30 - Began the JT-019 schema-ownership retirement with an executable 49-table ownership partition and transferred the leaf `SystemEmailSettings` table from MariaDB-only startup DDL to an additive provider-aware migration. Fresh SQLite now receives the table; legacy rows are preserved and startup no longer creates it. - Transferred the independent `UserRuleSettings` table from both provider startup paths to its own provider-aware migration; owner-keyed settings survive adoption, downgrade and retry. - Moved `GmailReviewDecisions` into a provider-aware migration, preserving existing SQLite decisions and closing the previously missing MariaDB table path. +- Moved recovery codes, trusted devices, and revocable user sessions into one provider-aware authentication-support migration; populated legacy rows and indexes survive adoption, downgrade, and retry. ### In progress @@ -76,7 +77,7 @@ Updated: 2026-08-30 - Focused frontend: 2 suites, 6 tests passed. - Full frontend: 64 suites, 272 tests passed. - Next production build and TypeScript: passed. -- Full backend: 724/724 tests passed after the third JT-019 ownership transfer. +- Full backend: 725/725 tests passed after the authentication-support JT-019 transfer. - Portable Playwright launcher: resolved the user-local .NET 9 SDK; backend Release build passed with 0 warnings/errors. - Playwright: initial full run 9/10 exposed the intentional mobile Settings control change; updated focused rerun passed 1/1. A final complete browser rerun remains in the end-of-batch gate. - Focused backend match/intelligence verification: 34/34 passed.