diff options
| author | Leitwolf11 | 2026-08-20 20:49:49 +0300 |
|---|---|---|
| committer | Leitwolf11 | 2026-08-20 20:49:49 +0300 |
| commit | 9bc662fefa4dad26274d18bbde7a48cf6ec78d19 (patch) | |
| tree | d60ff141c6e7b38f94477552540699e102011d01 | |
| parent | b3a82aa98d1edb2570c1ca441be40e891b6f4be3 (diff) | |
| download | website-9bc662fefa4dad26274d18bbde7a48cf6ec78d19.tar.gz | |
Add verified publisher usernames to registry signup
Insert a dedicated username step after the password screen and require the same publisher identity after Google authorization. Enforce an uppercase ASCII first letter, ASCII alphanumeric content, 8-128 character bounds, and reserved Progmasoft and Leitwolf names in the backend rather than trusting browser validation.
Preserve canonical case while enforcing case-insensitive ownership through a normalized unique PostgreSQL index. Claim the name only after email-code validation and commit code consumption plus ownership atomically so unverified accounts cannot squat publisher coordinates. Expose the canonical publisher name on the account model, add the EF migration and xUnit policy coverage, version the cached auth flow, document the contract, and run backend tests in CI.
| -rw-r--r-- | .github/workflows/ci.yml | 3 | ||||
| -rw-r--r-- | README.md | 3 | ||||
| -rw-r--r-- | backend.Tests/Auth/PublisherNamePolicyTests.cs | 84 | ||||
| -rw-r--r-- | backend.Tests/XSharp.Web.Api.Tests.csproj | 24 | ||||
| -rw-r--r-- | backend/Auth/AuthEndpoints.cs | 111 | ||||
| -rw-r--r-- | backend/Auth/PublisherNamePolicy.cs | 56 | ||||
| -rw-r--r-- | backend/Data/ApplicationUser.cs | 2 | ||||
| -rw-r--r-- | backend/Data/Migrations/20260820173435_AddPublisherNames.Designer.cs | 393 | ||||
| -rw-r--r-- | backend/Data/Migrations/20260820173435_AddPublisherNames.cs | 57 | ||||
| -rw-r--r-- | backend/Data/Migrations/RegistryDbContextModelSnapshot.cs | 12 | ||||
| -rw-r--r-- | backend/Data/RegistryDbContext.cs | 6 | ||||
| -rw-r--r-- | backend/Properties/AssemblyInfo.cs | 6 | ||||
| -rw-r--r-- | frontend/public/scripts/auth-flow.v3.js (renamed from frontend/public/scripts/auth-flow.v2.js) | 35 | ||||
| -rw-r--r-- | frontend/src/components/AuthPanel.astro | 30 |
14 files changed, 798 insertions, 24 deletions
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 287e896..d864f94 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -35,5 +35,6 @@ jobs: - uses: actions/setup-dotnet@v6 with: dotnet-version: 10.0.x - - run: dotnet restore backend/XSharp.Web.Api.csproj + - run: dotnet restore backend.Tests/XSharp.Web.Api.Tests.csproj - run: dotnet build backend/XSharp.Web.Api.csproj --no-restore --configuration Release + - run: dotnet test backend.Tests/XSharp.Web.Api.Tests.csproj --no-restore --configuration Release @@ -36,6 +36,9 @@ The Google OAuth web client callback is `Authentication__Google__ClientId` and `Authentication__Google__ClientSecret`. Until both values exist, the frontend keeps the Google button visible but disabled and email/password authentication remains available. Every completed Google authorization still requires the short-lived Visual X# email code before a registry session is issued. +New accounts choose a publisher username before email verification. The canonical spelling is case-sensitive and must contain +8–128 ASCII letters or digits, beginning with an uppercase letter. Ownership is case-insensitively unique, so case-only +variants cannot impersonate an existing publisher; `Progmasoft` and `Leitwolf` are reserved. ## Requirements diff --git a/backend.Tests/Auth/PublisherNamePolicyTests.cs b/backend.Tests/Auth/PublisherNamePolicyTests.cs new file mode 100644 index 0000000..290c379 --- /dev/null +++ b/backend.Tests/Auth/PublisherNamePolicyTests.cs @@ -0,0 +1,84 @@ +// SPDX-FileCopyrightText: 2026 Leitwolf <xs-lang.chess031@slmails.com> +// SPDX-License-Identifier: AGPL-3.0-or-later + +using XSharp.Web.Api.Auth; +using Xunit; + +namespace XSharp.Web.Api.Tests.Auth; + +public sealed class PublisherNamePolicyTests +{ + [Theory] + [InlineData("Myname12")] + [InlineData("MyName12")] + [InlineData("Publisher123")] + [InlineData("A2345678")] + public void AcceptsCanonicalAsciiNames(string value) + { + PublisherNameValidation result = PublisherNamePolicy.Validate(value); + + Assert.True(result.IsValid); + Assert.Equal(value, result.Value); + Assert.Equal(value.ToUpperInvariant(), result.NormalizedValue); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData("Short")] + [InlineData("myname12")] + [InlineData("myName12")] + [InlineData("Şpublisher")] + [InlineData("Üsername1")] + [InlineData("中文Publisher")] + [InlineData("한국Publisher")] + [InlineData("MyName|1")] + [InlineData("MyName\\1")] + [InlineData("MyName/1")] + [InlineData("MyName&1")] + [InlineData("MyName!1")] + [InlineData("MyName'1")] + [InlineData("MyName^1")] + [InlineData("MyName#1")] + [InlineData("My_Name1")] + [InlineData("My-Name1")] + [InlineData("My Name1")] + public void RejectsNonCanonicalNames(string? value) + { + Assert.False(PublisherNamePolicy.Validate(value).IsValid); + } + + [Theory] + [InlineData("Progmasoft")] + [InlineData("PROGMASOFT")] + [InlineData("Leitwolf")] + [InlineData("LEITWOLF")] + public void RejectsReservedNamesWithoutCaseLoopholes(string value) + { + PublisherNameValidation result = PublisherNamePolicy.Validate(value); + + Assert.False(result.IsValid); + Assert.Equal("This username is reserved.", result.Error); + } + + [Fact] + public void CaseVariantsShareOneOwnershipKey() + { + PublisherNameValidation first = PublisherNamePolicy.Validate("Myname12"); + PublisherNameValidation second = PublisherNamePolicy.Validate("MyName12"); + + Assert.True(first.IsValid); + Assert.True(second.IsValid); + Assert.Equal(first.NormalizedValue, second.NormalizedValue); + } + + [Fact] + public void EnforcesMaximumLength() + { + string maximum = $"A{new string('a', PublisherNamePolicy.MaximumLength - 1)}"; + string tooLong = maximum + "a"; + + Assert.True(PublisherNamePolicy.Validate(maximum).IsValid); + Assert.False(PublisherNamePolicy.Validate(tooLong).IsValid); + } +} diff --git a/backend.Tests/XSharp.Web.Api.Tests.csproj b/backend.Tests/XSharp.Web.Api.Tests.csproj new file mode 100644 index 0000000..fb09512 --- /dev/null +++ b/backend.Tests/XSharp.Web.Api.Tests.csproj @@ -0,0 +1,24 @@ +<Project Sdk="Microsoft.NET.Sdk"> + <!-- SPDX-FileCopyrightText: 2026 Leitwolf <xs-lang.chess031@slmails.com> --> + <!-- SPDX-License-Identifier: AGPL-3.0-or-later --> + <PropertyGroup> + <TargetFramework>net10.0</TargetFramework> + <ImplicitUsings>enable</ImplicitUsings> + <Nullable>enable</Nullable> + <IsPackable>false</IsPackable> + <TreatWarningsAsErrors>true</TreatWarningsAsErrors> + <AssemblyName>XSharp.Web.Api.Tests</AssemblyName> + <RootNamespace>XSharp.Web.Api.Tests</RootNamespace> + </PropertyGroup> + <ItemGroup> + <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" /> + <PackageReference Include="xunit" Version="2.9.3" /> + <PackageReference Include="xunit.runner.visualstudio" Version="3.1.4"> + <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> + <PrivateAssets>all</PrivateAssets> + </PackageReference> + </ItemGroup> + <ItemGroup> + <ProjectReference Include="..\backend\XSharp.Web.Api.csproj" /> + </ItemGroup> +</Project> diff --git a/backend/Auth/AuthEndpoints.cs b/backend/Auth/AuthEndpoints.cs index d984727..ca3e16b 100644 --- a/backend/Auth/AuthEndpoints.cs +++ b/backend/Auth/AuthEndpoints.cs @@ -5,6 +5,9 @@ using System.Security.Claims; using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.RateLimiting; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Storage; +using Npgsql; using XSharp.Web.Api.Data; using XSharp.Web.Api.Email; @@ -34,16 +37,28 @@ internal static class AuthEndpoints } private static async Task<IResult> RegisterAsync( - Credentials request, + RegisterRequest request, UserManager<ApplicationUser> users, AuthCodeService codes, RegistryEmailSender emailSender, CancellationToken cancellationToken) { - if (!TryGetCredentials(request, out string email, out string password)) + if (!TryGetCredentials(request.Email, request.Password, out string email, out string password)) { return InvalidRequest(); } + PublisherNameValidation publisherName = PublisherNamePolicy.Validate(request.PublisherName); + if (!publisherName.IsValid) + { + return InvalidPublisherName(publisherName.Error!); + } + if (await PublisherNameExistsAsync(users, publisherName.NormalizedValue, cancellationToken)) + { + return PublisherNameUnavailable(); + } + + // Availability is checked before sending mail for fast feedback, but the name is claimed only after the email code + // succeeds. Unverified accounts therefore cannot squat publisher coordinates. ApplicationUser user = new() { UserName = email, Email = email }; IdentityResult result = await users.CreateAsync(user, password); if (!result.Succeeded) @@ -61,6 +76,7 @@ internal static class AuthEndpoints UserManager<ApplicationUser> users, AuthCodeService codes, SignInManager<ApplicationUser> signIn, + RegistryDbContext database, CancellationToken cancellationToken) { if (!TryNormalizeEmail(request.Email, out string email) || string.IsNullOrWhiteSpace(request.Code)) @@ -68,14 +84,47 @@ internal static class AuthEndpoints return InvalidRequest(); } ApplicationUser? user = await users.FindByEmailAsync(email); - if (user is null || !await codes.ConsumeAsync(user.Id, "signup", request.Code, cancellationToken)) + if (user is null) { return Results.BadRequest(new ApiError("invalid_code", "The verification code is invalid or expired.")); } + await using IDbContextTransaction transaction = await database.Database.BeginTransactionAsync(cancellationToken); + PublisherNameValidation publisherName = default; + if (user.PublisherName is null) + { + publisherName = PublisherNamePolicy.Validate(request.PublisherName); + if (!publisherName.IsValid) + { + return InvalidPublisherName(publisherName.Error!); + } + if (await PublisherNameExistsAsync(users, publisherName.NormalizedValue, cancellationToken)) + { + return PublisherNameUnavailable(); + } + } + if (!await codes.ConsumeAsync(user.Id, "signup", request.Code, cancellationToken)) + { + return Results.BadRequest(new ApiError("invalid_code", "The verification code is invalid or expired.")); + } + + if (user.PublisherName is null) + { + user.PublisherName = publisherName.Value; + user.NormalizedPublisherName = publisherName.NormalizedValue; + } user.EmailConfirmed = true; - IdentityResult result = await users.UpdateAsync(user); + IdentityResult result; + try + { + result = await users.UpdateAsync(user); + } + catch (DbUpdateException exception) when (IsPublisherNameConflict(exception)) + { + return PublisherNameUnavailable(); + } if (!result.Succeeded) return IdentityErrors(result); + await transaction.CommitAsync(cancellationToken); await signIn.SignInAsync(user, isPersistent: true); return Results.Ok(new { verified = true }); } @@ -104,7 +153,7 @@ internal static class AuthEndpoints UserManager<ApplicationUser> users, SignInManager<ApplicationUser> signIn) { - if (!TryGetCredentials(request, out string email, out string password)) + if (!TryGetCredentials(request.Email, request.Password, out string email, out string password)) { return InvalidCredentials(); } @@ -128,13 +177,21 @@ internal static class AuthEndpoints return Results.NoContent(); } - private static IResult MeAsync(ClaimsPrincipal principal) => Results.Ok(new + private static async Task<IResult> MeAsync( + ClaimsPrincipal principal, + UserManager<ApplicationUser> users) { - id = principal.FindFirstValue(ClaimTypes.NameIdentifier), - email = principal.FindFirstValue(ClaimTypes.Email) ?? principal.Identity?.Name, - displayName = principal.FindFirstValue(ClaimTypes.Name) ?? principal.Identity?.Name, - emailVerified = true, - }); + ApplicationUser? user = await users.GetUserAsync(principal); + if (user is null) return Results.Unauthorized(); + return Results.Ok(new + { + id = user.Id, + email = user.Email, + publisherName = user.PublisherName, + displayName = user.PublisherName ?? user.Email, + emailVerified = user.EmailConfirmed, + }); + } private static async Task<IResult> ProvidersAsync(IAuthenticationSchemeProvider schemes) => Results.Ok(new { google = await schemes.GetSchemeAsync("Google") is not null }); @@ -262,12 +319,31 @@ internal static class AuthEndpoints return Results.Ok(new { recovered = true }); } - private static bool TryGetCredentials(Credentials request, out string email, out string password) + private static bool TryGetCredentials( + string? requestedEmail, + string? requestedPassword, + out string email, + out string password) { - password = request.Password ?? string.Empty; - return TryNormalizeEmail(request.Email, out email) && password.Length > 0; + password = requestedPassword ?? string.Empty; + return TryNormalizeEmail(requestedEmail, out email) && password.Length > 0; } + private static Task<bool> PublisherNameExistsAsync( + UserManager<ApplicationUser> users, + string normalizedPublisherName, + CancellationToken cancellationToken) => + users.Users.AnyAsync( + user => user.NormalizedPublisherName == normalizedPublisherName, + cancellationToken); + + private static bool IsPublisherNameConflict(DbUpdateException exception) => + exception.InnerException is PostgresException + { + SqlState: PostgresErrorCodes.UniqueViolation, + ConstraintName: "PublisherNameIndex", + }; + private static bool TryNormalizeEmail(string? value, out string email) { email = value?.Trim().ToLowerInvariant() ?? string.Empty; @@ -280,13 +356,18 @@ internal static class AuthEndpoints private static IResult InvalidCredentials() => Results.Json(new ApiError("invalid_credentials", "The email or password is incorrect."), statusCode: StatusCodes.Status401Unauthorized); + private static IResult InvalidPublisherName(string message) => + Results.BadRequest(new ApiError("invalid_publisher_name", message)); + private static IResult PublisherNameUnavailable() => + Results.Conflict(new ApiError("publisher_name_unavailable", "This username is not available.")); private static IResult IdentityErrors(IdentityResult result) => Results.BadRequest(new { error = "identity_validation", errors = result.Errors.Select(item => item.Description) }); } +internal sealed record RegisterRequest(string? Email, string? Password, string? PublisherName); internal sealed record Credentials(string? Email, string? Password); internal sealed record EmailRequest(string? Email); -internal sealed record VerificationRequest(string? Email, string? Code); +internal sealed record VerificationRequest(string? Email, string? Code, string? PublisherName); internal sealed record RecoveryRequest(string? Email, string? Code, string? Password); internal sealed record DeleteAccountRequest(string? Confirmation); internal sealed record ApiError(string Error, string Message); diff --git a/backend/Auth/PublisherNamePolicy.cs b/backend/Auth/PublisherNamePolicy.cs new file mode 100644 index 0000000..9b687ab --- /dev/null +++ b/backend/Auth/PublisherNamePolicy.cs @@ -0,0 +1,56 @@ +// SPDX-FileCopyrightText: 2026 Leitwolf <xs-lang.chess031@slmails.com> +// SPDX-License-Identifier: AGPL-3.0-or-later + +namespace XSharp.Web.Api.Auth; + +internal static class PublisherNamePolicy +{ + public const int MinimumLength = 8; + public const int MaximumLength = 128; + + private static readonly string[] ReservedNames = ["Progmasoft", "Leitwolf"]; + + public static PublisherNameValidation Validate(string? value) + { + string publisherName = value ?? string.Empty; + if (publisherName.Length is < MinimumLength or > MaximumLength) + { + return PublisherNameValidation.Invalid( + $"Username must be between {MinimumLength} and {MaximumLength} characters."); + } + if (!IsUpperAscii(publisherName[0])) + { + return PublisherNameValidation.Invalid("Username must start with an uppercase ASCII letter."); + } + + // Publisher coordinates are URL path segments. Restricting the complete value to ASCII letters and digits keeps + // canonical package URLs portable across filesystems, shells, registries, and case-sensitive clients. + if (!publisherName.All(IsAsciiLetterOrDigit)) + { + return PublisherNameValidation.Invalid("Username can contain only ASCII letters and digits."); + } + if (ReservedNames.Contains(publisherName, StringComparer.OrdinalIgnoreCase)) + { + return PublisherNameValidation.Invalid("This username is reserved."); + } + + return PublisherNameValidation.Valid(publisherName, publisherName.ToUpperInvariant()); + } + + private static bool IsUpperAscii(char value) => value is >= 'A' and <= 'Z'; + + private static bool IsAsciiLetterOrDigit(char value) => + value is >= 'A' and <= 'Z' or >= 'a' and <= 'z' or >= '0' and <= '9'; +} + +internal readonly record struct PublisherNameValidation( + bool IsValid, + string Value, + string NormalizedValue, + string? Error) +{ + public static PublisherNameValidation Valid(string value, string normalizedValue) => + new(true, value, normalizedValue, null); + + public static PublisherNameValidation Invalid(string error) => new(false, string.Empty, string.Empty, error); +} diff --git a/backend/Data/ApplicationUser.cs b/backend/Data/ApplicationUser.cs index 984029f..f02da66 100644 --- a/backend/Data/ApplicationUser.cs +++ b/backend/Data/ApplicationUser.cs @@ -8,4 +8,6 @@ namespace XSharp.Web.Api.Data; internal sealed class ApplicationUser : IdentityUser<Guid> { public DateTimeOffset CreatedAt { get; init; } = DateTimeOffset.UtcNow; + public string? PublisherName { get; set; } + public string? NormalizedPublisherName { get; set; } } diff --git a/backend/Data/Migrations/20260820173435_AddPublisherNames.Designer.cs b/backend/Data/Migrations/20260820173435_AddPublisherNames.Designer.cs new file mode 100644 index 0000000..823b244 --- /dev/null +++ b/backend/Data/Migrations/20260820173435_AddPublisherNames.Designer.cs @@ -0,0 +1,393 @@ +// <auto-generated /> +// SPDX-FileCopyrightText: 2026 Leitwolf <xs-lang.chess031@slmails.com> +// SPDX-License-Identifier: AGPL-3.0-or-later +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using XSharp.Web.Api.Data; + +namespace XSharp.Web.Api.Data.Migrations +{ + [DbContext(typeof(RegistryDbContext))] + [Migration("20260820173435_AddPublisherNames")] + partial class AddPublisherNames + { + /// <inheritdoc /> + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasDefaultSchema("registry") + .HasAnnotation("ProductVersion", "10.0.2") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole<System.Guid>", b => + { + b.Property<Guid>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property<string>("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property<string>("Name") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property<string>("NormalizedName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("AspNetRoles", "registry"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<System.Guid>", b => + { + b.Property<int>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id")); + + b.Property<string>("ClaimType") + .HasColumnType("text"); + + b.Property<string>("ClaimValue") + .HasColumnType("text"); + + b.Property<Guid>("RoleId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", "registry"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<System.Guid>", b => + { + b.Property<int>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id")); + + b.Property<string>("ClaimType") + .HasColumnType("text"); + + b.Property<string>("ClaimValue") + .HasColumnType("text"); + + b.Property<Guid>("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", "registry"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<System.Guid>", b => + { + b.Property<string>("LoginProvider") + .HasColumnType("text"); + + b.Property<string>("ProviderKey") + .HasColumnType("text"); + + b.Property<string>("ProviderDisplayName") + .HasColumnType("text"); + + b.Property<Guid>("UserId") + .HasColumnType("uuid"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", "registry"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<System.Guid>", b => + { + b.Property<Guid>("UserId") + .HasColumnType("uuid"); + + b.Property<Guid>("RoleId") + .HasColumnType("uuid"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", "registry"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<System.Guid>", b => + { + b.Property<Guid>("UserId") + .HasColumnType("uuid"); + + b.Property<string>("LoginProvider") + .HasColumnType("text"); + + b.Property<string>("Name") + .HasColumnType("text"); + + b.Property<string>("Value") + .HasColumnType("text"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", "registry"); + }); + + modelBuilder.Entity("XSharp.Web.Api.Data.ApplicationUser", b => + { + b.Property<Guid>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property<int>("AccessFailedCount") + .HasColumnType("integer"); + + b.Property<string>("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property<DateTimeOffset>("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property<string>("Email") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property<bool>("EmailConfirmed") + .HasColumnType("boolean"); + + b.Property<bool>("LockoutEnabled") + .HasColumnType("boolean"); + + b.Property<DateTimeOffset?>("LockoutEnd") + .HasColumnType("timestamp with time zone"); + + b.Property<string>("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property<string>("NormalizedPublisherName") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property<string>("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property<string>("PasswordHash") + .HasColumnType("text"); + + b.Property<string>("PhoneNumber") + .HasColumnType("text"); + + b.Property<bool>("PhoneNumberConfirmed") + .HasColumnType("boolean"); + + b.Property<string>("PublisherName") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property<string>("SecurityStamp") + .HasColumnType("text"); + + b.Property<bool>("TwoFactorEnabled") + .HasColumnType("boolean"); + + b.Property<string>("UserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .IsUnique() + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedPublisherName") + .IsUnique() + .HasDatabaseName("PublisherNameIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("AspNetUsers", "registry"); + }); + + modelBuilder.Entity("XSharp.Web.Api.Data.AuthChallenge", b => + { + b.Property<Guid>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property<byte[]>("CodeDigest") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("bytea"); + + b.Property<DateTimeOffset?>("ConsumedAt") + .HasColumnType("timestamp with time zone"); + + b.Property<DateTimeOffset>("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property<int>("FailedAttempts") + .HasColumnType("integer"); + + b.Property<string>("Purpose") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property<Guid>("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "Purpose", "ConsumedAt"); + + b.ToTable("AuthChallenges", "registry"); + }); + + modelBuilder.Entity("XSharp.Web.Api.Data.RegistryToken", b => + { + b.Property<Guid>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property<DateTimeOffset>("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property<byte[]>("Digest") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("bytea"); + + b.Property<DateTimeOffset?>("LastUsedAt") + .HasColumnType("timestamp with time zone"); + + b.Property<string>("Name") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("character varying(80)"); + + b.Property<string>("Prefix") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property<DateTimeOffset?>("RevokedAt") + .HasColumnType("timestamp with time zone"); + + b.PrimitiveCollection<string[]>("Scopes") + .IsRequired() + .HasColumnType("text[]"); + + b.Property<Guid>("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("Digest") + .IsUnique(); + + b.HasIndex("UserId", "RevokedAt"); + + b.ToTable("RegistryTokens", "registry"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<System.Guid>", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole<System.Guid>", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<System.Guid>", b => + { + b.HasOne("XSharp.Web.Api.Data.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<System.Guid>", b => + { + b.HasOne("XSharp.Web.Api.Data.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<System.Guid>", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole<System.Guid>", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("XSharp.Web.Api.Data.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavio |