diff --git a/CHANGELOG.md b/CHANGELOG.md
index d1b6747..25363ae 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,16 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
+## [3.3.14] - 2026-04-26
+
+### Fixed
+
+- **Identity / PostgreSQL:** Removed redundant `users.JwtTokensJson` (historical JSON blob of sessions on the user row). **Server-side session allowlist semantics are unchanged:** issued sessions remain rows in `jwt_tokens` and are validated the same way as in Vault’s persisted `JwtToken` model—only the duplicate JSON encoding was dropped. New `users` inserts no longer hit `23502` on that column. FluentMigrator `DropUsersJwtTokensJson` (`20260426140000`) drops the column when present; the baseline no longer creates it; `JwtTokensTableMigrateFromJson` copies from JSON only if that column still exists (upgrades from older DBs).
+
+### Changed
+
+- **FluentMigrator:** `RestoreUsersJwtTokensJsonIfDropped` (`20260426120000`) is now a no-op (revision kept for databases that already applied it). Session material is stored only in `jwt_tokens`, not duplicated as JSON on `users`.
+
## [3.3.13] - 2026-04-26
### Fixed
@@ -29,13 +39,13 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
### Added
-- **Database:** FluentMigrator `RestoreUsersJwtTokensJsonIfDropped` (`20260426120000`) restores `users.JwtTokensJson` with `ADD COLUMN IF NOT EXISTS` when an older database had it removed by a prior `JwtTokensTableMigrateFromJson` revision.
+- **Database:** FluentMigrator `RestoreUsersJwtTokensJsonIfDropped` (`20260426120000`) initially re-added `users.JwtTokensJson` with `ADD COLUMN IF NOT EXISTS` for databases that had dropped it under an older `JwtTokensTableMigrateFromJson` revision. **Superseded in 3.3.14:** that revision is a no-op and `DropUsersJwtTokensJson` drops the column; tokens stay in `jwt_tokens` only.
- **Helm / config:** `certsServerConfig.configuration.certsUIEngineConfiguration.autoSyncSchema` (default `true`) is rendered into server `appsettings.json` so add-only schema sync runs on every startup unless explicitly disabled.
### Changed
-- **Startup schema policy:** Documented expand-only expectations — FluentMigrator `Up()` should add tables/columns; avoid dropping renamed or legacy columns in `Up()`. `JwtTokensTableMigrateFromJson` no longer drops `JwtTokensJson` (tokens remain in `jwt_tokens`; legacy JSON column may remain for audit).
-- **Schema sync:** `AutoSyncSchema` defaults to **true** in repo `appsettings.json`; `SchemaSyncService` desired map includes `users.IsActive`, `TwoFactorSharedKey`, and optional `JwtTokensJson` for additive repair. Still **ADD COLUMN IF NOT EXISTS** only (no DROP).
+- **Startup schema policy:** Documented expand-only expectations — FluentMigrator `Up()` should add tables/columns; avoid dropping renamed columns in routine `Up()` without an explicit follow-up plan. `JwtTokensTableMigrateFromJson` no longer drops `JwtTokensJson` in that revision’s `Up()` (tokens are normalized into `jwt_tokens`). **3.3.14** removes `JwtTokensJson` from the live schema via `DropUsersJwtTokensJson`.
+- **Schema sync:** `AutoSyncSchema` defaults to **true** in repo `appsettings.json`; `SchemaSyncService` desired map includes `users.IsActive` and `TwoFactorSharedKey`. **3.3.14** stops treating `JwtTokensJson` as a desired column. Still **ADD COLUMN IF NOT EXISTS** only (no DROP in sync).
- **ICertsEngineConfiguration / ISchemaSyncService:** Clarified that add-only sync is recommended and describes the no-DROP guarantee.
## [3.3.10] - 2026-04-26
diff --git a/src/MaksIT.CertsUI.Engine/Dto/Identity/UserDto.cs b/src/MaksIT.CertsUI.Engine/Dto/Identity/UserDto.cs
index 177037c..dc85f63 100644
--- a/src/MaksIT.CertsUI.Engine/Dto/Identity/UserDto.cs
+++ b/src/MaksIT.CertsUI.Engine/Dto/Identity/UserDto.cs
@@ -3,13 +3,13 @@ using MaksIT.Core.Abstractions.Dto;
namespace MaksIT.CertsUI.Engine.Dto.Identity;
///
-/// PostgreSQL users row (Linq2DB). JWT sessions live in jwt_tokens; is not a mapped column.
-/// A legacy JwtTokensJson column may still exist on the table for expand-only migrations; it is not mapped here.
+/// PostgreSQL users row (Linq2DB). JWT sessions are rows in jwt_tokens; is loaded separately, not a column on users.
///
public class UserDto : DtoDocumentBase {
public required string Name { get; set; }
public required string Salt { get; set; }
public required string Hash { get; set; }
+
public DateTime LastLoginUtc { get; set; }
public bool IsActive { get; set; } = true;
public string? TwoFactorSharedKey { get; set; }
diff --git a/src/MaksIT.CertsUI.Engine/FluentMigrations/20260415100000_BaselineCertsSchema.cs b/src/MaksIT.CertsUI.Engine/FluentMigrations/20260415100000_BaselineCertsSchema.cs
index d0ce63c..9c28a95 100644
--- a/src/MaksIT.CertsUI.Engine/FluentMigrations/20260415100000_BaselineCertsSchema.cs
+++ b/src/MaksIT.CertsUI.Engine/FluentMigrations/20260415100000_BaselineCertsSchema.cs
@@ -25,7 +25,6 @@ public class BaselineCertsSchema : Migration {
.WithColumn("Name").AsCustom("text").NotNullable()
.WithColumn("Salt").AsCustom("text").NotNullable()
.WithColumn("Hash").AsCustom("text").NotNullable()
- .WithColumn("JwtTokensJson").AsCustom("text").NotNullable()
.WithColumn("LastLoginUtc").AsDateTimeOffset().NotNullable();
Create.Index("IX_users_Name").OnTable("users").OnColumn("Name").Unique();
diff --git a/src/MaksIT.CertsUI.Engine/FluentMigrations/20260418100000_JwtTokensTableMigrateFromJson.cs b/src/MaksIT.CertsUI.Engine/FluentMigrations/20260418100000_JwtTokensTableMigrateFromJson.cs
index 1d23435..9de2f13 100644
--- a/src/MaksIT.CertsUI.Engine/FluentMigrations/20260418100000_JwtTokensTableMigrateFromJson.cs
+++ b/src/MaksIT.CertsUI.Engine/FluentMigrations/20260418100000_JwtTokensTableMigrateFromJson.cs
@@ -4,8 +4,8 @@ using FluentMigrator;
namespace MaksIT.CertsUI.Engine.FluentMigrations;
///
-/// Normalizes JWT refresh/access tokens into jwt_tokens (one row per token). Legacy users.JwtTokensJson is retained
-/// (expand-only policy); the app reads jwt_tokens only.
+/// Normalizes JWT refresh/access tokens into jwt_tokens (one row per token).
+/// If users.JwtTokensJson exists (older databases), rows are copied from that JSON; otherwise this step only creates jwt_tokens.
///
[Migration(20260418100000)]
public class JwtTokensTableMigrateFromJson : Migration {
@@ -29,24 +29,26 @@ public class JwtTokensTableMigrateFromJson : Migration {
Create.Index("IX_jwt_tokens_Token").OnTable("jwt_tokens").OnColumn("Token");
Create.Index("IX_jwt_tokens_RefreshToken").OnTable("jwt_tokens").OnColumn("RefreshToken");
- Execute.Sql("""
- INSERT INTO "jwt_tokens" ("Id", "UserId", "Token", "RefreshToken", "IssuedAt", "ExpiresAt", "RefreshTokenExpiresAt", "IsRevoked")
- SELECT
- (elem->>'Id')::uuid,
- u."Id",
- COALESCE(elem->>'Token', ''),
- COALESCE(elem->>'RefreshToken', ''),
- (elem->>'IssuedAt')::timestamptz,
- (elem->>'ExpiresAt')::timestamptz,
- (elem->>'RefreshTokenExpiresAt')::timestamptz,
- COALESCE((elem->>'IsRevoked')::boolean, false)
- FROM "users" u
- CROSS JOIN LATERAL json_array_elements(
- CASE WHEN u."JwtTokensJson" IS NULL OR btrim(u."JwtTokensJson"::text) = '' THEN '[]'::json
- ELSE u."JwtTokensJson"::json END
- ) AS elem
- WHERE (elem->>'Id') IS NOT NULL
- """);
+ if (Schema.Table("users").Column("JwtTokensJson").Exists()) {
+ Execute.Sql("""
+ INSERT INTO "jwt_tokens" ("Id", "UserId", "Token", "RefreshToken", "IssuedAt", "ExpiresAt", "RefreshTokenExpiresAt", "IsRevoked")
+ SELECT
+ (elem->>'Id')::uuid,
+ u."Id",
+ COALESCE(elem->>'Token', ''),
+ COALESCE(elem->>'RefreshToken', ''),
+ (elem->>'IssuedAt')::timestamptz,
+ (elem->>'ExpiresAt')::timestamptz,
+ (elem->>'RefreshTokenExpiresAt')::timestamptz,
+ COALESCE((elem->>'IsRevoked')::boolean, false)
+ FROM "users" u
+ CROSS JOIN LATERAL json_array_elements(
+ CASE WHEN u."JwtTokensJson" IS NULL OR btrim(u."JwtTokensJson"::text) = '' THEN '[]'::json
+ ELSE u."JwtTokensJson"::json END
+ ) AS elem
+ WHERE (elem->>'Id') IS NOT NULL
+ """);
+ }
}
public override void Down() =>
diff --git a/src/MaksIT.CertsUI.Engine/FluentMigrations/20260426120000_RestoreUsersJwtTokensJsonIfDropped.cs b/src/MaksIT.CertsUI.Engine/FluentMigrations/20260426120000_RestoreUsersJwtTokensJsonIfDropped.cs
index 680ca71..3c8aa80 100644
--- a/src/MaksIT.CertsUI.Engine/FluentMigrations/20260426120000_RestoreUsersJwtTokensJsonIfDropped.cs
+++ b/src/MaksIT.CertsUI.Engine/FluentMigrations/20260426120000_RestoreUsersJwtTokensJsonIfDropped.cs
@@ -3,15 +3,13 @@ using FluentMigrator;
namespace MaksIT.CertsUI.Engine.FluentMigrations;
///
-/// Databases that already applied when it still dropped JwtTokensJson
-/// get the column back (empty default). Expand-only: we never remove renamed/legacy columns in Up().
+/// Previously re-added users.JwtTokensJson when a prior migration had dropped that JSON column.
+/// removes the column; persisted sessions use jwt_tokens only (same role as Vault’s JwtToken rows).
///
[Migration(20260426120000)]
public class RestoreUsersJwtTokensJsonIfDropped : Migration {
public override void Up() {
- Execute.Sql("""
- ALTER TABLE "users" ADD COLUMN IF NOT EXISTS "JwtTokensJson" text NOT NULL DEFAULT '';
- """);
+ // No-op: revision kept so databases that already applied the old DDL remain valid; see DropUsersJwtTokensJson.
}
public override void Down() =>
diff --git a/src/MaksIT.CertsUI.Engine/FluentMigrations/20260426140000_DropUsersJwtTokensJson.cs b/src/MaksIT.CertsUI.Engine/FluentMigrations/20260426140000_DropUsersJwtTokensJson.cs
new file mode 100644
index 0000000..0a9db8b
--- /dev/null
+++ b/src/MaksIT.CertsUI.Engine/FluentMigrations/20260426140000_DropUsersJwtTokensJson.cs
@@ -0,0 +1,17 @@
+using FluentMigrator;
+
+namespace MaksIT.CertsUI.Engine.FluentMigrations;
+
+///
+/// Drops users.JwtTokensJson when present (old JSON copy of sessions). Sessions remain in jwt_tokens for server-side validation / allowlist behavior.
+///
+[Migration(20260426140000)]
+public class DropUsersJwtTokensJson : Migration {
+ public override void Up() {
+ if (Schema.Table("users").Column("JwtTokensJson").Exists())
+ Delete.Column("JwtTokensJson").FromTable("users");
+ }
+
+ public override void Down() =>
+ throw new NotSupportedException("Down is not supported; restore from backup if required.");
+}
diff --git a/src/MaksIT.CertsUI.Engine/Infrastructure/SchemaSyncService.cs b/src/MaksIT.CertsUI.Engine/Infrastructure/SchemaSyncService.cs
index 5d46e4a..88e9a7e 100644
--- a/src/MaksIT.CertsUI.Engine/Infrastructure/SchemaSyncService.cs
+++ b/src/MaksIT.CertsUI.Engine/Infrastructure/SchemaSyncService.cs
@@ -83,7 +83,6 @@ public class SchemaSyncService(ICertsEngineConfiguration config, ILogger
- 3.3.13
+ 3.3.14
net10.0
enable
enable