Read-only mirrors. Development, issues and pull requests are on GitHub.

summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorLeitwolf112026-08-20 20:49:49 +0300
committerLeitwolf112026-08-20 20:49:49 +0300
commit9bc662fefa4dad26274d18bbde7a48cf6ec78d19 (patch)
treed60ff141c6e7b38f94477552540699e102011d01
parentb3a82aa98d1edb2570c1ca441be40e891b6f4be3 (diff)
downloadwebsite-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.yml3
-rw-r--r--README.md3
-rw-r--r--backend.Tests/Auth/PublisherNamePolicyTests.cs84
-rw-r--r--backend.Tests/XSharp.Web.Api.Tests.csproj24
-rw-r--r--backend/Auth/AuthEndpoints.cs111
-rw-r--r--backend/Auth/PublisherNamePolicy.cs56
-rw-r--r--backend/Data/ApplicationUser.cs2
-rw-r--r--backend/Data/Migrations/20260820173435_AddPublisherNames.Designer.cs393
-rw-r--r--backend/Data/Migrations/20260820173435_AddPublisherNames.cs57
-rw-r--r--backend/Data/Migrations/RegistryDbContextModelSnapshot.cs12
-rw-r--r--backend/Data/RegistryDbContext.cs6
-rw-r--r--backend/Properties/AssemblyInfo.cs6
-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.astro30
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
diff --git a/README.md b/README.md
index 6eab391..396561a 100644
--- a/README.md
+++ b/README.md
@@ -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