diff --git a/src/Bifrost.Web/Configuration/ConceptVoteOptions.cs b/src/Bifrost.Web/Configuration/ConceptVoteOptions.cs new file mode 100644 index 0000000..624832c --- /dev/null +++ b/src/Bifrost.Web/Configuration/ConceptVoteOptions.cs @@ -0,0 +1,22 @@ +namespace Bifrost.Web.Configuration; + +public sealed class ConceptVoteOptions +{ + public const string SectionName = "ConceptVotes"; + + // HMAC-SHA256 output is 32 bytes (RFC 2104 advises a key at least as long as the hash output), so a shorter + // secret makes the tag brute-forceable over the IPv4 space if the table leaks. + public const int MinNetworkTagSecretLength = 32; + + // Keys the network tag. Required outside Development and Testing, at least MinNetworkTagSecretLength + // characters (StartupConfigurationValidator). + public string NetworkTagSecret { get; init; } = string.Empty; + + // The research note (docs/research/anonymous-vote-stuffing-prior-art.md:57-61) finds no source for a + // coarser IPv4 partition than the address itself, so the default is the exact address. + public int IPv4PrefixLength { get; init; } = 32; + + // /64 is bypassed by RFC 8981 privacy rotation and households can hold a /56 or /48; /56 is the practitioner + // default weighed in the note, not a standard. Tune on observed abuse. + public int IPv6PrefixLength { get; init; } = 56; +} diff --git a/src/Bifrost.Web/Data/BifrostDbContext.cs b/src/Bifrost.Web/Data/BifrostDbContext.cs index 290aa08..9863cfd 100644 --- a/src/Bifrost.Web/Data/BifrostDbContext.cs +++ b/src/Bifrost.Web/Data/BifrostDbContext.cs @@ -63,6 +63,10 @@ public sealed class BifrostDbContext(DbContextOptions options) public DbSet AuditEvents => Set(); + public DbSet ConceptSubjects => Set(); + + public DbSet ConceptAppraisals => Set(); + protected override void OnModelCreating(ModelBuilder modelBuilder) { modelBuilder.Entity(entity => @@ -523,5 +527,31 @@ public sealed class BifrostDbContext(DbContextOptions options) .HasForeignKey(x => x.ActorUserAccountId) .OnDelete(DeleteBehavior.SetNull); }); + + modelBuilder.Entity(entity => + { + entity.Property(x => x.Collection).HasMaxLength(80); + entity.Property(x => x.Key).HasMaxLength(80); + entity.Property(x => x.Title).HasMaxLength(240); + entity.HasIndex(x => new { x.Collection, x.Key }).IsUnique(); + }); + + modelBuilder.Entity(entity => + { + entity.Property(x => x.VoterKey).HasMaxLength(120); + entity.Property(x => x.NetworkTag).HasMaxLength(64); + entity.Property(x => x.Stance).HasConversion().HasMaxLength(16); + entity.Property(x => x.VoterClass).HasConversion().HasMaxLength(16); + entity.HasIndex(x => new { x.SubjectId, x.VoterKey }).IsUnique(); + entity.HasIndex(x => new { x.NetworkTag, x.CastAtUtc }); + entity.HasOne(x => x.Subject) + .WithMany() + .HasForeignKey(x => x.SubjectId) + .OnDelete(DeleteBehavior.Restrict); + entity.HasOne() + .WithMany() + .HasForeignKey(x => x.UserAccountId) + .OnDelete(DeleteBehavior.SetNull); + }); } } diff --git a/src/Bifrost.Web/Data/Migrations/20261010043937_AddConceptVotes.Designer.cs b/src/Bifrost.Web/Data/Migrations/20261010043937_AddConceptVotes.Designer.cs new file mode 100644 index 0000000..6ffcfd9 --- /dev/null +++ b/src/Bifrost.Web/Data/Migrations/20261010043937_AddConceptVotes.Designer.cs @@ -0,0 +1,2197 @@ +// +using System; +using Bifrost.Web.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Bifrost.Web.Data.Migrations +{ + [DbContext(typeof(BifrostDbContext))] + [Migration("20261010043937_AddConceptVotes")] + partial class AddConceptVotes + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.0") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Bifrost.Web.Domain.AgentDispatchRun", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CompletedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Error") + .IsRequired() + .HasColumnType("text"); + + b.Property("LaunchMode") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("LogPath") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("Note") + .IsRequired() + .HasColumnType("text"); + + b.Property("RequestId") + .IsRequired() + .HasMaxLength(240) + .HasColumnType("character varying(240)"); + + b.Property("ResultPath") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("StartedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("StartedByUserAccountId") + .HasColumnType("uuid"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text"); + + b.Property("TargetAgentIdentity") + .IsRequired() + .HasMaxLength(160) + .HasColumnType("character varying(160)"); + + b.Property("TargetRepoName") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("TargetRepositoryFullName") + .IsRequired() + .HasMaxLength(240) + .HasColumnType("character varying(240)"); + + b.Property("ThreadId") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("TurnId") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("WorkerProcessId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("RequestId"); + + b.HasIndex("StartedByUserAccountId"); + + b.HasIndex("Status"); + + b.HasIndex("TargetRepoName", "TargetAgentIdentity", "Status"); + + b.ToTable("AgentDispatchRuns"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.AgentTransportReceipt", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ActivityKind") + .IsRequired() + .HasColumnType("text"); + + b.Property("ActorName") + .IsRequired() + .HasMaxLength(160) + .HasColumnType("character varying(160)"); + + b.Property("ActorUserAccountId") + .HasColumnType("uuid"); + + b.Property("Note") + .IsRequired() + .HasColumnType("text"); + + b.Property("OccurredAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("RequestId") + .IsRequired() + .HasMaxLength(240) + .HasColumnType("character varying(240)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("TargetAgentIdentity") + .IsRequired() + .HasMaxLength(160) + .HasColumnType("character varying(160)"); + + b.Property("TargetRepoName") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("TargetRepositoryFullName") + .IsRequired() + .HasMaxLength(240) + .HasColumnType("character varying(240)"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(240) + .HasColumnType("character varying(240)"); + + b.HasKey("Id"); + + b.HasIndex("ActorUserAccountId"); + + b.HasIndex("OccurredAtUtc"); + + b.HasIndex("RequestId"); + + b.HasIndex("TargetRepoName", "TargetAgentIdentity", "ActivityKind"); + + b.ToTable("AgentTransportReceipts"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.Assignment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AssignedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("AssignedByUserAccountId") + .HasColumnType("uuid"); + + b.Property("Note") + .IsRequired() + .HasColumnType("text"); + + b.Property("UserAccountId") + .HasColumnType("uuid"); + + b.Property("WorkItemId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("AssignedByUserAccountId"); + + b.HasIndex("UserAccountId"); + + b.HasIndex("WorkItemId"); + + b.ToTable("Assignments"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.AuditEvent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Action") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("ActorUserAccountId") + .HasColumnType("uuid"); + + b.Property("Detail") + .IsRequired() + .HasColumnType("text"); + + b.Property("EntityId") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("EntityType") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("OccurredAtUtc") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("ActorUserAccountId"); + + b.ToTable("AuditEvents"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.BridgeAction", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ActionKind") + .IsRequired() + .HasColumnType("text"); + + b.Property("ActorKind") + .IsRequired() + .HasColumnType("text"); + + b.Property("ActorName") + .IsRequired() + .HasMaxLength(160) + .HasColumnType("character varying(160)"); + + b.Property("ActorUserAccountId") + .HasColumnType("uuid"); + + b.Property("AuthorityReference") + .IsRequired() + .HasMaxLength(240) + .HasColumnType("character varying(240)"); + + b.Property("BifrostIdentity") + .IsRequired() + .HasMaxLength(160) + .HasColumnType("character varying(160)"); + + b.Property("CompletedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("EpiphanyAgentIdentity") + .IsRequired() + .HasMaxLength(160) + .HasColumnType("character varying(160)"); + + b.Property("EpiphanyLaneId") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("EpiphanyRunId") + .IsRequired() + .HasMaxLength(160) + .HasColumnType("character varying(160)"); + + b.Property("ExternalReceiptId") + .IsRequired() + .HasMaxLength(240) + .HasColumnType("character varying(240)"); + + b.Property("FailureReason") + .IsRequired() + .HasColumnType("text"); + + b.Property("HeimdallCapabilityReference") + .IsRequired() + .HasMaxLength(240) + .HasColumnType("character varying(240)"); + + b.Property("MotionId") + .HasColumnType("uuid"); + + b.Property("PolicyDecision") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("ReceiptPayload") + .IsRequired() + .HasColumnType("text"); + + b.Property("ReceiptUrl") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("RequestedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("SourceId") + .IsRequired() + .HasMaxLength(240) + .HasColumnType("character varying(240)"); + + b.Property("SourceKind") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("StartedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text"); + + b.Property("Summary") + .IsRequired() + .HasColumnType("text"); + + b.Property("TargetLocator") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("TargetRepositoryFullName") + .IsRequired() + .HasMaxLength(240) + .HasColumnType("character varying(240)"); + + b.Property("TargetSurface") + .IsRequired() + .HasColumnType("text"); + + b.Property("TargetSurfaceName") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(240) + .HasColumnType("character varying(240)"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("WorkItemId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ActorUserAccountId"); + + b.HasIndex("BifrostIdentity"); + + b.HasIndex("HeimdallCapabilityReference"); + + b.HasIndex("MotionId"); + + b.HasIndex("Status"); + + b.HasIndex("WorkItemId"); + + b.HasIndex("ActorKind", "ActorName"); + + b.HasIndex("SourceKind", "SourceId"); + + b.HasIndex("TargetSurface", "ActionKind", "Status"); + + b.HasIndex("TargetSurface", "TargetSurfaceName", "Status"); + + b.ToTable("BridgeActions"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.ConceptAppraisal", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CastAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("NetworkTag") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Stance") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("SubjectId") + .HasColumnType("uuid"); + + b.Property("UserAccountId") + .HasColumnType("uuid"); + + b.Property("VoterClass") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("VoterKey") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.HasKey("Id"); + + b.HasIndex("UserAccountId"); + + b.HasIndex("NetworkTag", "CastAtUtc"); + + b.HasIndex("SubjectId", "VoterKey") + .IsUnique(); + + b.ToTable("ConceptAppraisals"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.ConceptSubject", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Collection") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("character varying(80)"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("character varying(80)"); + + b.Property("RegisteredAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("RetiredAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(240) + .HasColumnType("character varying(240)"); + + b.HasKey("Id"); + + b.HasIndex("Collection", "Key") + .IsUnique(); + + b.ToTable("ConceptSubjects"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.DecayRun", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Notes") + .IsRequired() + .HasColumnType("text"); + + b.Property("RanAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Rate") + .HasPrecision(8, 4) + .HasColumnType("numeric(8,4)"); + + b.HasKey("Id"); + + b.ToTable("DecayRuns"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.GitHubIssueLink", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("IssueNumber") + .HasColumnType("integer"); + + b.Property("IssueUrl") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("LastSynchronizedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("RepositoryFullName") + .IsRequired() + .HasMaxLength(240) + .HasColumnType("character varying(240)"); + + b.Property("State") + .IsRequired() + .HasColumnType("text"); + + b.Property("TitleSnapshot") + .IsRequired() + .HasMaxLength(240) + .HasColumnType("character varying(240)"); + + b.Property("WorkItemId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("WorkItemId") + .IsUnique(); + + b.HasIndex("RepositoryFullName", "IssueNumber") + .IsUnique(); + + b.ToTable("GitHubIssueLinks"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.GitHubPullRequestLink", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("IsMerged") + .HasColumnType("boolean"); + + b.Property("LastSynchronizedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("PullRequestNumber") + .HasColumnType("integer"); + + b.Property("PullRequestUrl") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("RepositoryFullName") + .IsRequired() + .HasMaxLength(240) + .HasColumnType("character varying(240)"); + + b.Property("ReviewDecision") + .IsRequired() + .HasColumnType("text"); + + b.Property("State") + .IsRequired() + .HasColumnType("text"); + + b.Property("TitleSnapshot") + .IsRequired() + .HasMaxLength(240) + .HasColumnType("character varying(240)"); + + b.Property("WorkItemId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("WorkItemId"); + + b.HasIndex("RepositoryFullName", "PullRequestNumber") + .IsUnique(); + + b.ToTable("GitHubPullRequestLinks"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.GovernanceActivityReceipt", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ActivityKind") + .IsRequired() + .HasColumnType("text"); + + b.Property("ActorKind") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("ActorName") + .IsRequired() + .HasMaxLength(160) + .HasColumnType("character varying(160)"); + + b.Property("ActorUserAccountId") + .HasColumnType("uuid"); + + b.Property("CommentId") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("DispatchRequestId") + .IsRequired() + .HasMaxLength(240) + .HasColumnType("character varying(240)"); + + b.Property("JurisdictionAgentIdentity") + .IsRequired() + .HasMaxLength(160) + .HasColumnType("character varying(160)"); + + b.Property("JurisdictionRepoName") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("Note") + .IsRequired() + .HasColumnType("text"); + + b.Property("OccurredAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(240) + .HasColumnType("character varying(240)"); + + b.Property("TopicId") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.HasKey("Id"); + + b.HasIndex("ActorUserAccountId"); + + b.HasIndex("OccurredAtUtc"); + + b.HasIndex("TopicId"); + + b.HasIndex("JurisdictionRepoName", "JurisdictionAgentIdentity", "ActivityKind"); + + b.ToTable("GovernanceActivityReceipts"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.LedgerEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedByUserAccountId") + .HasColumnType("uuid"); + + b.Property("EffectiveAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("EntryType") + .IsRequired() + .HasColumnType("text"); + + b.Property("NominalAmount") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("Note") + .IsRequired() + .HasColumnType("text"); + + b.Property("Points") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("ProjectId") + .HasColumnType("uuid"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text"); + + b.Property("UserAccountId") + .HasColumnType("uuid"); + + b.Property("WorkItemId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("CreatedByUserAccountId"); + + b.HasIndex("ProjectId"); + + b.HasIndex("UserAccountId"); + + b.HasIndex("WorkItemId"); + + b.ToTable("LedgerEntries"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.MemberProfile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Availability") + .IsRequired() + .HasColumnType("text"); + + b.Property("Headline") + .IsRequired() + .HasMaxLength(240) + .HasColumnType("character varying(240)"); + + b.Property("Nickname") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("Notes") + .IsRequired() + .HasColumnType("text"); + + b.Property("PortfolioUrl") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("Skills") + .IsRequired() + .HasColumnType("text"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UserAccountId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserAccountId") + .IsUnique(); + + b.ToTable("MemberProfiles"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.Membership", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ApprovedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CanManageLedger") + .HasColumnType("boolean"); + + b.Property("CanManageProjects") + .HasColumnType("boolean"); + + b.Property("CanModerateMotions") + .HasColumnType("boolean"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("IsPlatformAdmin") + .HasColumnType("boolean"); + + b.Property("Notes") + .IsRequired() + .HasColumnType("text"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text"); + + b.Property("UserAccountId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserAccountId") + .IsUnique(); + + b.ToTable("Memberships"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.MembershipInvitation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AcceptedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("AcceptedByUserAccountId") + .HasColumnType("uuid"); + + b.Property("GitHubLogin") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("IssuedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("IssuedByUserAccountId") + .HasColumnType("uuid"); + + b.Property("NormalizedGitHubLogin") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("Notes") + .IsRequired() + .HasColumnType("text"); + + b.Property("RevokedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("AcceptedByUserAccountId"); + + b.HasIndex("IssuedByUserAccountId"); + + b.HasIndex("NormalizedGitHubLogin") + .IsUnique(); + + b.ToTable("MembershipInvitations"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.Motion", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ApprovalThreshold") + .HasPrecision(6, 3) + .HasColumnType("numeric(6,3)"); + + b.Property("Category") + .IsRequired() + .HasColumnType("text"); + + b.Property("ClosesAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedByUserAccountId") + .HasColumnType("uuid"); + + b.Property("OpensAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("ProjectId") + .HasColumnType("uuid"); + + b.Property("ResolutionNote") + .IsRequired() + .HasColumnType("text"); + + b.Property("ResolvedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Scope") + .IsRequired() + .HasColumnType("text"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text"); + + b.Property("Summary") + .IsRequired() + .HasColumnType("text"); + + b.Property("Title") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("CreatedByUserAccountId"); + + b.HasIndex("ProjectId"); + + b.ToTable("Motions"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.PatronSupportEvent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Amount") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("CurrencyCode") + .IsRequired() + .HasMaxLength(12) + .HasColumnType("character varying(12)"); + + b.Property("ExternalSupportId") + .IsRequired() + .HasMaxLength(240) + .HasColumnType("character varying(240)"); + + b.Property("IsCurrentRecurringSupport") + .HasColumnType("boolean"); + + b.Property("Kind") + .IsRequired() + .HasColumnType("text"); + + b.Property("Notes") + .IsRequired() + .HasColumnType("text"); + + b.Property("ProjectId") + .HasColumnType("uuid"); + + b.Property("Provider") + .IsRequired() + .HasColumnType("text"); + + b.Property("ProviderEventId") + .IsRequired() + .HasMaxLength(240) + .HasColumnType("character varying(240)"); + + b.Property("ProviderPayerId") + .IsRequired() + .HasMaxLength(240) + .HasColumnType("character varying(240)"); + + b.Property("ProviderSubscriptionId") + .IsRequired() + .HasMaxLength(240) + .HasColumnType("character varying(240)"); + + b.Property("RecordedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("SupportedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UserAccountId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ProjectId"); + + b.HasIndex("Provider", "ProviderEventId") + .IsUnique() + .HasFilter("\"ProviderEventId\" <> ''"); + + b.HasIndex("UserAccountId", "Kind", "IsCurrentRecurringSupport"); + + b.ToTable("PatronSupportEvents"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.PayoutProposalBatch", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedByUserAccountId") + .HasColumnType("uuid"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text"); + + b.Property("Summary") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("CreatedByUserAccountId"); + + b.ToTable("PayoutProposalBatches"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.PointTransaction", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Amount") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("IsDecaying") + .HasColumnType("boolean"); + + b.Property("Note") + .IsRequired() + .HasColumnType("text"); + + b.Property("ProjectId") + .HasColumnType("uuid"); + + b.Property("Type") + .IsRequired() + .HasColumnType("text"); + + b.Property("UserAccountId") + .HasColumnType("uuid"); + + b.Property("WorkItemId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ProjectId"); + + b.HasIndex("UserAccountId"); + + b.HasIndex("WorkItemId"); + + b.ToTable("PointTransactions"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.Project", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("GitHubRepository") + .IsRequired() + .HasMaxLength(240) + .HasColumnType("character varying(240)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(180) + .HasColumnType("character varying(180)"); + + b.Property("OwnerUserAccountId") + .HasColumnType("uuid"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text"); + + b.Property("Summary") + .IsRequired() + .HasColumnType("text"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("GitHubRepository"); + + b.HasIndex("OwnerUserAccountId"); + + b.HasIndex("Slug") + .IsUnique(); + + b.ToTable("Projects"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.RevenueEvent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Amount") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("CurrencyCode") + .IsRequired() + .HasMaxLength(12) + .HasColumnType("character varying(12)"); + + b.Property("Notes") + .IsRequired() + .HasColumnType("text"); + + b.Property("ProjectId") + .HasColumnType("uuid"); + + b.Property("ReceivedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("ProjectId"); + + b.ToTable("RevenueEvents"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.RevenueShareBatch", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedByUserAccountId") + .HasColumnType("uuid"); + + b.Property("RevenueEventId") + .HasColumnType("uuid"); + + b.Property("Summary") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("CreatedByUserAccountId"); + + b.HasIndex("RevenueEventId"); + + b.ToTable("RevenueShareBatches"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.RevenueShareLine", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Amount") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("Basis") + .IsRequired() + .HasMaxLength(240) + .HasColumnType("character varying(240)"); + + b.Property("Notes") + .IsRequired() + .HasColumnType("text"); + + b.Property("ProjectId") + .HasColumnType("uuid"); + + b.Property("RevenueShareBatchId") + .HasColumnType("uuid"); + + b.Property("UserAccountId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ProjectId"); + + b.HasIndex("RevenueShareBatchId"); + + b.HasIndex("UserAccountId"); + + b.ToTable("RevenueShareLines"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.RoleAssignment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AssignedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("AssignedByUserAccountId") + .HasColumnType("uuid"); + + b.Property("MembershipId") + .HasColumnType("uuid"); + + b.Property("Notes") + .IsRequired() + .HasColumnType("text"); + + b.Property("Role") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("AssignedByUserAccountId"); + + b.HasIndex("MembershipId", "Role") + .IsUnique(); + + b.ToTable("RoleAssignments"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.TierSnapshot", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CapturedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("EffectiveFromUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("IsCurrent") + .HasColumnType("boolean"); + + b.Property("Kind") + .IsRequired() + .HasColumnType("text"); + + b.Property("Label") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("MembershipId") + .HasColumnType("uuid"); + + b.Property("Notes") + .IsRequired() + .HasColumnType("text"); + + b.Property("Weight") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.HasKey("Id"); + + b.HasIndex("MembershipId", "Kind", "IsCurrent"); + + b.ToTable("TierSnapshots"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.UserAccount", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AvatarUrl") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("BifrostIdentity") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("GitHubLogin") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("GitHubUserId") + .HasColumnType("bigint"); + + b.Property("HeimdallAccountId") + .IsRequired() + .HasMaxLength(160) + .HasColumnType("character varying(160)"); + + b.Property("LastSeenAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("NormalizedBifrostIdentity") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("NormalizedGitHubLogin") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.HasKey("Id"); + + b.HasIndex("GitHubUserId") + .IsUnique() + .HasFilter("\"GitHubUserId\" IS NOT NULL"); + + b.HasIndex("HeimdallAccountId") + .IsUnique() + .HasFilter("\"HeimdallAccountId\" <> ''"); + + b.HasIndex("NormalizedBifrostIdentity") + .IsUnique() + .HasFilter("\"NormalizedBifrostIdentity\" <> ''"); + + b.HasIndex("NormalizedGitHubLogin") + .IsUnique() + .HasFilter("\"NormalizedGitHubLogin\" <> ''"); + + b.ToTable("UserAccounts"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.VolunteerClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Note") + .IsRequired() + .HasColumnType("text"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text"); + + b.Property("UserAccountId") + .HasColumnType("uuid"); + + b.Property("WorkItemId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserAccountId"); + + b.HasIndex("WorkItemId", "UserAccountId") + .IsUnique(); + + b.ToTable("VolunteerClaims"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.Vote", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CastAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Choice") + .IsRequired() + .HasColumnType("text"); + + b.Property("Comment") + .IsRequired() + .HasColumnType("text"); + + b.Property("MotionId") + .HasColumnType("uuid"); + + b.Property("UserAccountId") + .HasColumnType("uuid"); + + b.Property("Weight") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.HasKey("Id"); + + b.HasIndex("UserAccountId"); + + b.HasIndex("MotionId", "UserAccountId") + .IsUnique(); + + b.ToTable("Votes"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.WorkItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AssignedToUserAccountId") + .HasColumnType("uuid"); + + b.Property("Category") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("CompletedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("ContributionPoints") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("EstimatedHours") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("ExternalSourceId") + .IsRequired() + .HasMaxLength(240) + .HasColumnType("character varying(240)"); + + b.Property("ProjectId") + .HasColumnType("uuid"); + + b.Property("RequestedByUserAccountId") + .HasColumnType("uuid"); + + b.Property("ReviewStatus") + .IsRequired() + .HasColumnType("text"); + + b.Property("SkillLevel") + .IsRequired() + .HasColumnType("text"); + + b.Property("SourceType") + .IsRequired() + .HasColumnType("text"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text"); + + b.Property("SubmittedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Summary") + .IsRequired() + .HasColumnType("text"); + + b.Property("TargetDateUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(180) + .HasColumnType("character varying(180)"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("AssignedToUserAccountId"); + + b.HasIndex("ProjectId"); + + b.HasIndex("RequestedByUserAccountId"); + + b.ToTable("WorkItems"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.WorkLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ApprovalStatus") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Hours") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("Note") + .IsRequired() + .HasColumnType("text"); + + b.Property("ReviewedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("ReviewedByUserAccountId") + .HasColumnType("uuid"); + + b.Property("UserAccountId") + .HasColumnType("uuid"); + + b.Property("WorkItemId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ReviewedByUserAccountId"); + + b.HasIndex("UserAccountId"); + + b.HasIndex("WorkItemId"); + + b.ToTable("WorkLogs"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.WorkReview", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Note") + .IsRequired() + .HasColumnType("text"); + + b.Property("ReviewedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("ReviewerName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ReviewerUserAccountId") + .HasColumnType("uuid"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text"); + + b.Property("WorkItemId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ReviewerUserAccountId"); + + b.HasIndex("WorkItemId"); + + b.ToTable("WorkReviews"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.AgentDispatchRun", b => + { + b.HasOne("Bifrost.Web.Domain.UserAccount", "StartedByUserAccount") + .WithMany("AgentDispatchRuns") + .HasForeignKey("StartedByUserAccountId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("StartedByUserAccount"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.AgentTransportReceipt", b => + { + b.HasOne("Bifrost.Web.Domain.UserAccount", "ActorUserAccount") + .WithMany("AgentTransportReceipts") + .HasForeignKey("ActorUserAccountId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("ActorUserAccount"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.Assignment", b => + { + b.HasOne("Bifrost.Web.Domain.UserAccount", "AssignedByUserAccount") + .WithMany() + .HasForeignKey("AssignedByUserAccountId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Bifrost.Web.Domain.UserAccount", "UserAccount") + .WithMany("Assignments") + .HasForeignKey("UserAccountId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Bifrost.Web.Domain.WorkItem", "WorkItem") + .WithMany("Assignments") + .HasForeignKey("WorkItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AssignedByUserAccount"); + + b.Navigation("UserAccount"); + + b.Navigation("WorkItem"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.AuditEvent", b => + { + b.HasOne("Bifrost.Web.Domain.UserAccount", "ActorUserAccount") + .WithMany("AuditEvents") + .HasForeignKey("ActorUserAccountId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("ActorUserAccount"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.BridgeAction", b => + { + b.HasOne("Bifrost.Web.Domain.UserAccount", "ActorUserAccount") + .WithMany("BridgeActions") + .HasForeignKey("ActorUserAccountId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Bifrost.Web.Domain.Motion", "Motion") + .WithMany() + .HasForeignKey("MotionId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Bifrost.Web.Domain.WorkItem", "WorkItem") + .WithMany() + .HasForeignKey("WorkItemId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("ActorUserAccount"); + + b.Navigation("Motion"); + + b.Navigation("WorkItem"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.ConceptAppraisal", b => + { + b.HasOne("Bifrost.Web.Domain.ConceptSubject", "Subject") + .WithMany() + .HasForeignKey("SubjectId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Bifrost.Web.Domain.UserAccount", null) + .WithMany() + .HasForeignKey("UserAccountId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("Subject"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.GitHubIssueLink", b => + { + b.HasOne("Bifrost.Web.Domain.WorkItem", "WorkItem") + .WithOne("GitHubIssueLink") + .HasForeignKey("Bifrost.Web.Domain.GitHubIssueLink", "WorkItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("WorkItem"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.GitHubPullRequestLink", b => + { + b.HasOne("Bifrost.Web.Domain.WorkItem", "WorkItem") + .WithMany("GitHubPullRequestLinks") + .HasForeignKey("WorkItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("WorkItem"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.GovernanceActivityReceipt", b => + { + b.HasOne("Bifrost.Web.Domain.UserAccount", "ActorUserAccount") + .WithMany("GovernanceActivityReceipts") + .HasForeignKey("ActorUserAccountId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("ActorUserAccount"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.LedgerEntry", b => + { + b.HasOne("Bifrost.Web.Domain.UserAccount", "CreatedByUserAccount") + .WithMany() + .HasForeignKey("CreatedByUserAccountId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Bifrost.Web.Domain.Project", "Project") + .WithMany("LedgerEntries") + .HasForeignKey("ProjectId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Bifrost.Web.Domain.UserAccount", "UserAccount") + .WithMany("LedgerEntries") + .HasForeignKey("UserAccountId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Bifrost.Web.Domain.WorkItem", "WorkItem") + .WithMany("LedgerEntries") + .HasForeignKey("WorkItemId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("CreatedByUserAccount"); + + b.Navigation("Project"); + + b.Navigation("UserAccount"); + + b.Navigation("WorkItem"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.MemberProfile", b => + { + b.HasOne("Bifrost.Web.Domain.UserAccount", "UserAccount") + .WithOne("MemberProfile") + .HasForeignKey("Bifrost.Web.Domain.MemberProfile", "UserAccountId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("UserAccount"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.Membership", b => + { + b.HasOne("Bifrost.Web.Domain.UserAccount", "UserAccount") + .WithOne("Membership") + .HasForeignKey("Bifrost.Web.Domain.Membership", "UserAccountId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("UserAccount"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.MembershipInvitation", b => + { + b.HasOne("Bifrost.Web.Domain.UserAccount", "AcceptedByUserAccount") + .WithMany() + .HasForeignKey("AcceptedByUserAccountId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Bifrost.Web.Domain.UserAccount", "IssuedByUserAccount") + .WithMany() + .HasForeignKey("IssuedByUserAccountId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("AcceptedByUserAccount"); + + b.Navigation("IssuedByUserAccount"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.Motion", b => + { + b.HasOne("Bifrost.Web.Domain.UserAccount", "CreatedByUserAccount") + .WithMany("CreatedMotions") + .HasForeignKey("CreatedByUserAccountId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Bifrost.Web.Domain.Project", "Project") + .WithMany("Motions") + .HasForeignKey("ProjectId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("CreatedByUserAccount"); + + b.Navigation("Project"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.PatronSupportEvent", b => + { + b.HasOne("Bifrost.Web.Domain.Project", "Project") + .WithMany("PatronSupportEvents") + .HasForeignKey("ProjectId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Bifrost.Web.Domain.UserAccount", "UserAccount") + .WithMany("PatronSupportEvents") + .HasForeignKey("UserAccountId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Project"); + + b.Navigation("UserAccount"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.PayoutProposalBatch", b => + { + b.HasOne("Bifrost.Web.Domain.UserAccount", "CreatedByUserAccount") + .WithMany() + .HasForeignKey("CreatedByUserAccountId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("CreatedByUserAccount"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.PointTransaction", b => + { + b.HasOne("Bifrost.Web.Domain.Project", "Project") + .WithMany("PointTransactions") + .HasForeignKey("ProjectId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Bifrost.Web.Domain.UserAccount", "UserAccount") + .WithMany("PointTransactions") + .HasForeignKey("UserAccountId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Bifrost.Web.Domain.WorkItem", "WorkItem") + .WithMany("PointTransactions") + .HasForeignKey("WorkItemId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("Project"); + + b.Navigation("UserAccount"); + + b.Navigation("WorkItem"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.Project", b => + { + b.HasOne("Bifrost.Web.Domain.UserAccount", "OwnerUserAccount") + .WithMany("OwnedProjects") + .HasForeignKey("OwnerUserAccountId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("OwnerUserAccount"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.RevenueEvent", b => + { + b.HasOne("Bifrost.Web.Domain.Project", "Project") + .WithMany("RevenueEvents") + .HasForeignKey("ProjectId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("Project"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.RevenueShareBatch", b => + { + b.HasOne("Bifrost.Web.Domain.UserAccount", "CreatedByUserAccount") + .WithMany() + .HasForeignKey("CreatedByUserAccountId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Bifrost.Web.Domain.RevenueEvent", "RevenueEvent") + .WithMany("RevenueShareBatches") + .HasForeignKey("RevenueEventId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("CreatedByUserAccount"); + + b.Navigation("RevenueEvent"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.RevenueShareLine", b => + { + b.HasOne("Bifrost.Web.Domain.Project", "Project") + .WithMany("RevenueShareLines") + .HasForeignKey("ProjectId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Bifrost.Web.Domain.RevenueShareBatch", "RevenueShareBatch") + .WithMany("Lines") + .HasForeignKey("RevenueShareBatchId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bifrost.Web.Domain.UserAccount", "UserAccount") + .WithMany() + .HasForeignKey("UserAccountId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("Project"); + + b.Navigation("RevenueShareBatch"); + + b.Navigation("UserAccount"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.RoleAssignment", b => + { + b.HasOne("Bifrost.Web.Domain.UserAccount", "AssignedByUserAccount") + .WithMany() + .HasForeignKey("AssignedByUserAccountId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Bifrost.Web.Domain.Membership", "Membership") + .WithMany("RoleAssignments") + .HasForeignKey("MembershipId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AssignedByUserAccount"); + + b.Navigation("Membership"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.TierSnapshot", b => + { + b.HasOne("Bifrost.Web.Domain.Membership", "Membership") + .WithMany("TierSnapshots") + .HasForeignKey("MembershipId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Membership"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.VolunteerClaim", b => + { + b.HasOne("Bifrost.Web.Domain.UserAccount", "UserAccount") + .WithMany("VolunteerClaims") + .HasForeignKey("UserAccountId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Bifrost.Web.Domain.WorkItem", "WorkItem") + .WithMany("VolunteerClaims") + .HasForeignKey("WorkItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("UserAccount"); + + b.Navigation("WorkItem"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.Vote", b => + { + b.HasOne("Bifrost.Web.Domain.Motion", "Motion") + .WithMany("Votes") + .HasForeignKey("MotionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bifrost.Web.Domain.UserAccount", "UserAccount") + .WithMany("Votes") + .HasForeignKey("UserAccountId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Motion"); + + b.Navigation("UserAccount"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.WorkItem", b => + { + b.HasOne("Bifrost.Web.Domain.UserAccount", "AssignedToUserAccount") + .WithMany("AssignedWorkItems") + .HasForeignKey("AssignedToUserAccountId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Bifrost.Web.Domain.Project", "Project") + .WithMany("WorkItems") + .HasForeignKey("ProjectId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bifrost.Web.Domain.UserAccount", "RequestedByUserAccount") + .WithMany("RequestedWorkItems") + .HasForeignKey("RequestedByUserAccountId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("AssignedToUserAccount"); + + b.Navigation("Project"); + + b.Navigation("RequestedByUserAccount"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.WorkLog", b => + { + b.HasOne("Bifrost.Web.Domain.UserAccount", "ReviewedByUserAccount") + .WithMany() + .HasForeignKey("ReviewedByUserAccountId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Bifrost.Web.Domain.UserAccount", "UserAccount") + .WithMany("WorkLogs") + .HasForeignKey("UserAccountId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Bifrost.Web.Domain.WorkItem", "WorkItem") + .WithMany("WorkLogs") + .HasForeignKey("WorkItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("ReviewedByUserAccount"); + + b.Navigation("UserAccount"); + + b.Navigation("WorkItem"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.WorkReview", b => + { + b.HasOne("Bifrost.Web.Domain.UserAccount", "ReviewerUserAccount") + .WithMany("WorkReviews") + .HasForeignKey("ReviewerUserAccountId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Bifrost.Web.Domain.WorkItem", "WorkItem") + .WithMany("WorkReviews") + .HasForeignKey("WorkItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("ReviewerUserAccount"); + + b.Navigation("WorkItem"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.Membership", b => + { + b.Navigation("RoleAssignments"); + + b.Navigation("TierSnapshots"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.Motion", b => + { + b.Navigation("Votes"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.Project", b => + { + b.Navigation("LedgerEntries"); + + b.Navigation("Motions"); + + b.Navigation("PatronSupportEvents"); + + b.Navigation("PointTransactions"); + + b.Navigation("RevenueEvents"); + + b.Navigation("RevenueShareLines"); + + b.Navigation("WorkItems"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.RevenueEvent", b => + { + b.Navigation("RevenueShareBatches"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.RevenueShareBatch", b => + { + b.Navigation("Lines"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.UserAccount", b => + { + b.Navigation("AgentDispatchRuns"); + + b.Navigation("AgentTransportReceipts"); + + b.Navigation("AssignedWorkItems"); + + b.Navigation("Assignments"); + + b.Navigation("AuditEvents"); + + b.Navigation("BridgeActions"); + + b.Navigation("CreatedMotions"); + + b.Navigation("GovernanceActivityReceipts"); + + b.Navigation("LedgerEntries"); + + b.Navigation("MemberProfile"); + + b.Navigation("Membership"); + + b.Navigation("OwnedProjects"); + + b.Navigation("PatronSupportEvents"); + + b.Navigation("PointTransactions"); + + b.Navigation("RequestedWorkItems"); + + b.Navigation("VolunteerClaims"); + + b.Navigation("Votes"); + + b.Navigation("WorkLogs"); + + b.Navigation("WorkReviews"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.WorkItem", b => + { + b.Navigation("Assignments"); + + b.Navigation("GitHubIssueLink"); + + b.Navigation("GitHubPullRequestLinks"); + + b.Navigation("LedgerEntries"); + + b.Navigation("PointTransactions"); + + b.Navigation("VolunteerClaims"); + + b.Navigation("WorkLogs"); + + b.Navigation("WorkReviews"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Bifrost.Web/Data/Migrations/20261010043937_AddConceptVotes.cs b/src/Bifrost.Web/Data/Migrations/20261010043937_AddConceptVotes.cs new file mode 100644 index 0000000..8f26567 --- /dev/null +++ b/src/Bifrost.Web/Data/Migrations/20261010043937_AddConceptVotes.cs @@ -0,0 +1,93 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Bifrost.Web.Data.Migrations +{ + /// + public partial class AddConceptVotes : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "ConceptSubjects", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + Collection = table.Column(type: "character varying(80)", maxLength: 80, nullable: false), + Key = table.Column(type: "character varying(80)", maxLength: 80, nullable: false), + Title = table.Column(type: "character varying(240)", maxLength: 240, nullable: false), + RegisteredAtUtc = table.Column(type: "timestamp with time zone", nullable: false), + RetiredAtUtc = table.Column(type: "timestamp with time zone", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_ConceptSubjects", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "ConceptAppraisals", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + SubjectId = table.Column(type: "uuid", nullable: false), + VoterKey = table.Column(type: "character varying(120)", maxLength: 120, nullable: false), + Stance = table.Column(type: "character varying(16)", maxLength: 16, nullable: false), + VoterClass = table.Column(type: "character varying(16)", maxLength: 16, nullable: false), + UserAccountId = table.Column(type: "uuid", nullable: true), + NetworkTag = table.Column(type: "character varying(64)", maxLength: 64, nullable: false), + CastAtUtc = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_ConceptAppraisals", x => x.Id); + table.ForeignKey( + name: "FK_ConceptAppraisals_ConceptSubjects_SubjectId", + column: x => x.SubjectId, + principalTable: "ConceptSubjects", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + table.ForeignKey( + name: "FK_ConceptAppraisals_UserAccounts_UserAccountId", + column: x => x.UserAccountId, + principalTable: "UserAccounts", + principalColumn: "Id", + onDelete: ReferentialAction.SetNull); + }); + + migrationBuilder.CreateIndex( + name: "IX_ConceptAppraisals_NetworkTag_CastAtUtc", + table: "ConceptAppraisals", + columns: new[] { "NetworkTag", "CastAtUtc" }); + + migrationBuilder.CreateIndex( + name: "IX_ConceptAppraisals_SubjectId_VoterKey", + table: "ConceptAppraisals", + columns: new[] { "SubjectId", "VoterKey" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_ConceptAppraisals_UserAccountId", + table: "ConceptAppraisals", + column: "UserAccountId"); + + migrationBuilder.CreateIndex( + name: "IX_ConceptSubjects_Collection_Key", + table: "ConceptSubjects", + columns: new[] { "Collection", "Key" }, + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "ConceptAppraisals"); + + migrationBuilder.DropTable( + name: "ConceptSubjects"); + } + } +} diff --git a/src/Bifrost.Web/Data/Migrations/BifrostDbContextModelSnapshot.cs b/src/Bifrost.Web/Data/Migrations/BifrostDbContextModelSnapshot.cs index b12e9dc..b8c50c5 100644 --- a/src/Bifrost.Web/Data/Migrations/BifrostDbContextModelSnapshot.cs +++ b/src/Bifrost.Web/Data/Migrations/BifrostDbContextModelSnapshot.cs @@ -367,15 +367,15 @@ namespace Bifrost.Web.Data.Migrations .HasMaxLength(240) .HasColumnType("character varying(240)"); + b.Property("TargetSurface") + .IsRequired() + .HasColumnType("text"); + b.Property("TargetSurfaceName") .IsRequired() .HasMaxLength(120) .HasColumnType("character varying(120)"); - b.Property("TargetSurface") - .IsRequired() - .HasColumnType("text"); - b.Property("Title") .IsRequired() .HasMaxLength(240) @@ -412,6 +412,88 @@ namespace Bifrost.Web.Data.Migrations b.ToTable("BridgeActions"); }); + modelBuilder.Entity("Bifrost.Web.Domain.ConceptAppraisal", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CastAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("NetworkTag") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Stance") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("SubjectId") + .HasColumnType("uuid"); + + b.Property("UserAccountId") + .HasColumnType("uuid"); + + b.Property("VoterClass") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("VoterKey") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.HasKey("Id"); + + b.HasIndex("UserAccountId"); + + b.HasIndex("NetworkTag", "CastAtUtc"); + + b.HasIndex("SubjectId", "VoterKey") + .IsUnique(); + + b.ToTable("ConceptAppraisals"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.ConceptSubject", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Collection") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("character varying(80)"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("character varying(80)"); + + b.Property("RegisteredAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("RetiredAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(240) + .HasColumnType("character varying(240)"); + + b.HasKey("Id"); + + b.HasIndex("Collection", "Key") + .IsUnique(); + + b.ToTable("ConceptSubjects"); + }); + modelBuilder.Entity("Bifrost.Web.Domain.DecayRun", b => { b.Property("Id") @@ -889,6 +971,9 @@ namespace Bifrost.Web.Data.Migrations .IsRequired() .HasColumnType("text"); + b.Property("ProjectId") + .HasColumnType("uuid"); + b.Property("Provider") .IsRequired() .HasColumnType("text"); @@ -908,9 +993,6 @@ namespace Bifrost.Web.Data.Migrations .HasMaxLength(240) .HasColumnType("character varying(240)"); - b.Property("ProjectId") - .HasColumnType("uuid"); - b.Property("RecordedAtUtc") .HasColumnType("timestamp with time zone"); @@ -922,12 +1004,12 @@ namespace Bifrost.Web.Data.Migrations b.HasKey("Id"); + b.HasIndex("ProjectId"); + b.HasIndex("Provider", "ProviderEventId") .IsUnique() .HasFilter("\"ProviderEventId\" <> ''"); - b.HasIndex("ProjectId"); - b.HasIndex("UserAccountId", "Kind", "IsCurrentRecurringSupport"); b.ToTable("PatronSupportEvents"); @@ -1615,6 +1697,22 @@ namespace Bifrost.Web.Data.Migrations b.Navigation("WorkItem"); }); + modelBuilder.Entity("Bifrost.Web.Domain.ConceptAppraisal", b => + { + b.HasOne("Bifrost.Web.Domain.ConceptSubject", "Subject") + .WithMany() + .HasForeignKey("SubjectId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Bifrost.Web.Domain.UserAccount", null) + .WithMany() + .HasForeignKey("UserAccountId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("Subject"); + }); + modelBuilder.Entity("Bifrost.Web.Domain.GitHubIssueLink", b => { b.HasOne("Bifrost.Web.Domain.WorkItem", "WorkItem") diff --git a/src/Bifrost.Web/Domain/ConceptVoteModels.cs b/src/Bifrost.Web/Domain/ConceptVoteModels.cs new file mode 100644 index 0000000..b86e051 --- /dev/null +++ b/src/Bifrost.Web/Domain/ConceptVoteModels.cs @@ -0,0 +1,50 @@ +namespace Bifrost.Web.Domain; + +public enum ConceptStance +{ + Up, + Down +} + +public enum VoterClass +{ + Anonymous, + Account, + Member +} + +public sealed class ConceptSubject +{ + public Guid Id { get; set; } = Guid.NewGuid(); + + public string Collection { get; set; } = string.Empty; + + public string Key { get; set; } = string.Empty; + + public string Title { get; set; } = string.Empty; + + public DateTimeOffset RegisteredAtUtc { get; set; } + + public DateTimeOffset? RetiredAtUtc { get; set; } +} + +public sealed class ConceptAppraisal +{ + public Guid Id { get; set; } = Guid.NewGuid(); + + public Guid SubjectId { get; set; } + + public string VoterKey { get; set; } = string.Empty; + + public ConceptStance Stance { get; set; } + + public VoterClass VoterClass { get; set; } + + public Guid? UserAccountId { get; set; } + + public string NetworkTag { get; set; } = string.Empty; + + public DateTimeOffset CastAtUtc { get; set; } + + public ConceptSubject? Subject { get; set; } +} diff --git a/src/Bifrost.Web/Features/ConceptVotes/ConceptAppraisalService.cs b/src/Bifrost.Web/Features/ConceptVotes/ConceptAppraisalService.cs new file mode 100644 index 0000000..11893ad --- /dev/null +++ b/src/Bifrost.Web/Features/ConceptVotes/ConceptAppraisalService.cs @@ -0,0 +1,271 @@ +using System.Net; +using System.Text.RegularExpressions; +using Bifrost.Web.Configuration; +using Bifrost.Web.Data; +using Bifrost.Web.Domain; +using Bifrost.Web.Features.Shared; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Options; +using Npgsql; + +namespace Bifrost.Web.Features.ConceptVotes; + +public enum CastResult +{ + Recorded, + Changed, + Unchanged, + UnknownSubject, + RetiredSubject, + NoVoter +} + +public sealed record StanceCounts(int Up, int Down); + +public sealed record ConceptTally(int Up, int Down, StanceCounts Members); + +public sealed record ConceptSubjectView( + string Collection, + string Key, + string Title, + ConceptTally Tally, + ConceptStance? YourStance); + +// The only writer of ConceptAppraisal rows. Tallies are derived from rows on every read. +public sealed class ConceptAppraisalService( + BifrostDbContext dbContext, + AuditTrailService auditTrail, + TimeProvider timeProvider, + IOptions options) +{ + private const string UniqueViolation = "23505"; + + private static readonly Regex Slug = new("^[a-z0-9-]{1,80}$", RegexOptions.CultureInvariant); + + public async Task CastAsync( + string collection, + string key, + VoterIdentity? voter, + ConceptStance stance, + IPAddress? client, + CancellationToken cancellationToken) + { + if (voter is null) + { + return CastResult.NoVoter; + } + + var subject = await FindSubjectAsync(collection, key, cancellationToken); + if (subject is null) + { + return CastResult.UnknownSubject; + } + + if (subject.RetiredAtUtc is not null) + { + return CastResult.RetiredSubject; + } + + var opts = options.Value; + var tag = client is null + ? string.Empty + : NetworkPrefix.Tag(client, opts.IPv4PrefixLength, opts.IPv6PrefixLength, opts.NetworkTagSecret); + + var existing = await FindAppraisalAsync(subject.Id, voter.Key, cancellationToken); + if (existing is not null) + { + return await ApplyStanceAsync(existing, stance, tag, cancellationToken); + } + + var row = new ConceptAppraisal + { + SubjectId = subject.Id, + VoterKey = voter.Key, + Stance = stance, + VoterClass = voter.Class, + UserAccountId = voter.UserAccountId, + NetworkTag = tag, + CastAtUtc = timeProvider.GetUtcNow() + }; + dbContext.ConceptAppraisals.Add(row); + + try + { + await dbContext.SaveChangesAsync(cancellationToken); + return CastResult.Recorded; + } + catch (DbUpdateException ex) when (ex.InnerException is PostgresException { SqlState: UniqueViolation }) + { + // A concurrent cast by the same voter won the insert: apply this stance to their row, once. + dbContext.Entry(row).State = EntityState.Detached; + var winner = await FindAppraisalAsync(subject.Id, voter.Key, cancellationToken); + if (winner is null) + { + throw; + } + + return await ApplyStanceAsync(winner, stance, tag, cancellationToken); + } + } + + public async Task RetractAsync( + string collection, + string key, + VoterIdentity? voter, + CancellationToken cancellationToken) + { + if (voter is null) + { + return false; + } + + var subject = await FindSubjectAsync(collection, key, cancellationToken); + if (subject is null) + { + return false; + } + + var row = await FindAppraisalAsync(subject.Id, voter.Key, cancellationToken); + if (row is null) + { + return false; + } + + dbContext.ConceptAppraisals.Remove(row); + await dbContext.SaveChangesAsync(cancellationToken); + return true; + } + + public async Task PurgeAsync( + CurrentBifrostActor actor, + string networkTag, + DateTimeOffset fromUtc, + DateTimeOffset toUtc, + CancellationToken cancellationToken) + { + if (!actor.CanManageMembership || actor.UserAccount is null) + { + throw new UnauthorizedAccessException("Only a platform administrator can purge appraisals."); + } + + if (string.IsNullOrEmpty(networkTag)) + { + return 0; + } + + var rows = await dbContext.ConceptAppraisals + .Where(x => x.NetworkTag == networkTag && x.CastAtUtc >= fromUtc && x.CastAtUtc < toUtc) + .ToListAsync(cancellationToken); + dbContext.ConceptAppraisals.RemoveRange(rows); + + // The audit write saves the removals in the same SaveChanges. The member dashboard renders Detail, so it + // carries the window and count only: no network tag and no voter keys. The actor and window audit the purge. + await auditTrail.RecordAsync( + actor.UserAccount.Id, + nameof(ConceptAppraisal), + Guid.Empty, + "Purge", + $"from={fromUtc:O} to={toUtc:O} count={rows.Count}", + cancellationToken); + return rows.Count; + } + + public async Task> GetTalliesAsync( + string collection, + CancellationToken cancellationToken) + { + var live = await dbContext.ConceptSubjects.AsNoTracking() + .Where(x => x.Collection == collection && x.RetiredAtUtc == null) + .Select(x => new { x.Id, x.Key }) + .ToListAsync(cancellationToken); + var ids = live.Select(x => x.Id).ToList(); + + var groups = await dbContext.ConceptAppraisals.AsNoTracking() + .Where(x => ids.Contains(x.SubjectId)) + .GroupBy(x => new { x.SubjectId, x.Stance, IsMember = x.VoterClass == VoterClass.Member }) + .Select(g => new { g.Key.SubjectId, g.Key.Stance, g.Key.IsMember, Count = g.Count() }) + .ToListAsync(cancellationToken); + + return live.ToDictionary( + s => s.Key, + s => Tally(groups.Where(g => g.SubjectId == s.Id).Select(g => (g.Stance, g.IsMember, g.Count))), + StringComparer.Ordinal); + } + + public async Task GetSubjectAsync( + string collection, + string key, + VoterIdentity? voter, + CancellationToken cancellationToken) + { + var subject = await FindSubjectAsync(collection, key, cancellationToken); + if (subject is null || subject.RetiredAtUtc is not null) + { + return null; + } + + var groups = await dbContext.ConceptAppraisals.AsNoTracking() + .Where(x => x.SubjectId == subject.Id) + .GroupBy(x => new { x.Stance, IsMember = x.VoterClass == VoterClass.Member }) + .Select(g => new { g.Key.Stance, g.Key.IsMember, Count = g.Count() }) + .ToListAsync(cancellationToken); + var yours = voter is null + ? null + : (await FindAppraisalAsync(subject.Id, voter.Key, cancellationToken))?.Stance; + + return new ConceptSubjectView( + subject.Collection, + subject.Key, + subject.Title, + Tally(groups.Select(g => (g.Stance, g.IsMember, g.Count))), + yours); + } + + private static ConceptTally Tally(IEnumerable<(ConceptStance Stance, bool IsMember, int Count)> groups) + { + int up = 0, down = 0, memberUp = 0, memberDown = 0; + foreach (var (stance, isMember, count) in groups) + { + if (stance == ConceptStance.Up) + { + up += count; + memberUp += isMember ? count : 0; + } + else + { + down += count; + memberDown += isMember ? count : 0; + } + } + + return new ConceptTally(up, down, new StanceCounts(memberUp, memberDown)); + } + + private async Task ApplyStanceAsync( + ConceptAppraisal row, + ConceptStance stance, + string tag, + CancellationToken cancellationToken) + { + if (row.Stance == stance) + { + return CastResult.Unchanged; + } + + row.Stance = stance; + row.NetworkTag = tag; + row.CastAtUtc = timeProvider.GetUtcNow(); + await dbContext.SaveChangesAsync(cancellationToken); + return CastResult.Changed; + } + + private Task FindSubjectAsync(string collection, string key, CancellationToken cancellationToken) => + Slug.IsMatch(collection) && Slug.IsMatch(key) + ? dbContext.ConceptSubjects + .SingleOrDefaultAsync(x => x.Collection == collection && x.Key == key, cancellationToken) + : Task.FromResult(null); + + private Task FindAppraisalAsync(Guid subjectId, string voterKey, CancellationToken cancellationToken) => + dbContext.ConceptAppraisals + .SingleOrDefaultAsync(x => x.SubjectId == subjectId && x.VoterKey == voterKey, cancellationToken); +} diff --git a/src/Bifrost.Web/Features/ConceptVotes/NetworkPrefix.cs b/src/Bifrost.Web/Features/ConceptVotes/NetworkPrefix.cs new file mode 100644 index 0000000..6fd4213 --- /dev/null +++ b/src/Bifrost.Web/Features/ConceptVotes/NetworkPrefix.cs @@ -0,0 +1,45 @@ +using System.Net; +using System.Security.Cryptography; +using System.Text; + +namespace Bifrost.Web.Features.ConceptVotes; + +public static class NetworkPrefix +{ + public const int IPv4Bits = 32; + public const int IPv6Bits = 128; + + public static bool IsValidLength(int length, int addressBits) => length >= 0 && length <= addressBits; + + // The only prefix computation: IPv4-mapped IPv6 folds to IPv4, then the address is masked. Returns CIDR text. + public static string Of(IPAddress address, int ipv4Length, int ipv6Length) + { + if (address.IsIPv4MappedToIPv6) + { + address = address.MapToIPv4(); + } + + var bytes = address.GetAddressBytes(); + var length = bytes.Length == 4 ? ipv4Length : ipv6Length; + if (!IsValidLength(length, bytes.Length * 8)) + { + throw new ArgumentOutOfRangeException(nameof(ipv4Length), "Prefix length is outside the address family."); + } + + for (var i = 0; i < bytes.Length; i++) + { + var kept = Math.Clamp(length - i * 8, 0, 8); + bytes[i] &= (byte)(0xFF << (8 - kept)); + } + + return new IPAddress(bytes) + "/" + length; + } + + // Lowercase hex HMAC-SHA256 over the prefix text. Never reversible to an address; audit and purge only. + public static string Tag(IPAddress address, int ipv4Length, int ipv6Length, string secret) + { + var prefix = Of(address, ipv4Length, ipv6Length); + var mac = HMACSHA256.HashData(Encoding.UTF8.GetBytes(secret), Encoding.UTF8.GetBytes(prefix)); + return Convert.ToHexString(mac).ToLowerInvariant(); + } +} diff --git a/src/Bifrost.Web/Features/ConceptVotes/VoterIdentity.cs b/src/Bifrost.Web/Features/ConceptVotes/VoterIdentity.cs new file mode 100644 index 0000000..2ed2729 --- /dev/null +++ b/src/Bifrost.Web/Features/ConceptVotes/VoterIdentity.cs @@ -0,0 +1,28 @@ +using System.Text.RegularExpressions; +using Bifrost.Web.Domain; +using Bifrost.Web.Features.Shared; + +namespace Bifrost.Web.Features.ConceptVotes; + +public sealed record VoterIdentity(string Key, VoterClass Class, Guid? UserAccountId) +{ + public const int MaxKeyLength = 120; + + private static readonly Regex DeviceIdShape = new(@"^[A-Za-z0-9_-]{1,100}\z", RegexOptions.CultureInvariant); + + // The one place a voter key and class are decided. Never reads vote weight. + public static VoterIdentity? For(CurrentBifrostActor actor, string? deviceId) + { + if (actor.IsAuthenticated && actor.UserAccount is { } account) + { + return new VoterIdentity( + "account:" + account.Id.ToString("N"), + actor.IsActiveMember ? VoterClass.Member : VoterClass.Account, + account.Id); + } + + return deviceId is not null && DeviceIdShape.IsMatch(deviceId) + ? new VoterIdentity("device:" + deviceId, VoterClass.Anonymous, null) + : null; + } +} diff --git a/src/Bifrost.Web/Features/Shared/StartupConfigurationValidator.cs b/src/Bifrost.Web/Features/Shared/StartupConfigurationValidator.cs index 0ba0e10..eef6d84 100644 --- a/src/Bifrost.Web/Features/Shared/StartupConfigurationValidator.cs +++ b/src/Bifrost.Web/Features/Shared/StartupConfigurationValidator.cs @@ -1,4 +1,5 @@ using Bifrost.Web.Configuration; +using Bifrost.Web.Features.ConceptVotes; using Microsoft.Extensions.Options; namespace Bifrost.Web.Features.Shared; @@ -10,7 +11,8 @@ public sealed class StartupConfigurationValidator( IOptions gitHubAppOptions, IOptions heimdallOptions, IOptions stripeOptions, - IOptions hostOptions) + IOptions hostOptions, + IOptions conceptVoteOptions) { public void Validate() { @@ -71,6 +73,24 @@ public sealed class StartupConfigurationValidator( failures.Add("DataProtection:KeysPath must be configured for production."); } + var conceptVotes = conceptVoteOptions.Value; + if (string.IsNullOrWhiteSpace(conceptVotes.NetworkTagSecret) || + conceptVotes.NetworkTagSecret.Length < ConceptVoteOptions.MinNetworkTagSecretLength) + { + failures.Add( + $"ConceptVotes:NetworkTagSecret must be configured with at least {ConceptVoteOptions.MinNetworkTagSecretLength} characters for production."); + } + + if (!NetworkPrefix.IsValidLength(conceptVotes.IPv4PrefixLength, NetworkPrefix.IPv4Bits)) + { + failures.Add($"ConceptVotes:IPv4PrefixLength must be between 0 and {NetworkPrefix.IPv4Bits}."); + } + + if (!NetworkPrefix.IsValidLength(conceptVotes.IPv6PrefixLength, NetworkPrefix.IPv6Bits)) + { + failures.Add($"ConceptVotes:IPv6PrefixLength must be between 0 and {NetworkPrefix.IPv6Bits}."); + } + if (failures.Count > 0) { throw new InvalidOperationException( diff --git a/src/Bifrost.Web/Program.cs b/src/Bifrost.Web/Program.cs index 218a7ff..bec0271 100644 --- a/src/Bifrost.Web/Program.cs +++ b/src/Bifrost.Web/Program.cs @@ -6,6 +6,7 @@ using Bifrost.Web.Configuration; using Bifrost.Web.Data; using Bifrost.Web.Domain; using Bifrost.Web.Features.Bridge; +using Bifrost.Web.Features.ConceptVotes; using Bifrost.Web.Features.GitHub; using Bifrost.Web.Features.Heimdall; using Bifrost.Web.Features.Membership; @@ -53,6 +54,10 @@ builder.Services .AddOptions() .BindConfiguration(BridgeOptions.SectionName); +builder.Services + .AddOptions() + .BindConfiguration(ConceptVoteOptions.SectionName); + builder.Services.AddRazorPages(options => { options.Conventions.AuthorizePage("/Membership/Status"); @@ -97,6 +102,7 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddSingleton(); diff --git a/tests/Bifrost.Web.Tests/ConceptAppraisalServiceTests.cs b/tests/Bifrost.Web.Tests/ConceptAppraisalServiceTests.cs new file mode 100644 index 0000000..3bd6f8d --- /dev/null +++ b/tests/Bifrost.Web.Tests/ConceptAppraisalServiceTests.cs @@ -0,0 +1,600 @@ +using System.Net; +using System.Reflection; +using System.Text.Json; +using Bifrost.Web.Configuration; +using Bifrost.Web.Data; +using Bifrost.Web.Domain; +using Bifrost.Web.Features.ConceptVotes; +using Bifrost.Web.Features.Shared; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Diagnostics; +using Microsoft.Extensions.Options; +using Npgsql; + +namespace Bifrost.Web.Tests; + +public sealed class ConceptAppraisalServiceTests +{ + private const string Collection = "aetheria-concept-mill"; + private static readonly IPAddress Client = IPAddress.Parse("203.0.113.7"); + + [Fact] + public async Task Anonymous_device_voter_can_cast_and_is_counted() + { + await using var f = new Fixture(); + await f.AddSubject("ship-a"); + + var result = await f.Cast("ship-a", f.Anonymous("device-one"), ConceptStance.Up); + + Assert.Equal(CastResult.Recorded, result); + var tally = (await f.Service.GetTalliesAsync(Collection, default))["ship-a"]; + Assert.Equal(new ConceptTally(1, 0, new StanceCounts(0, 0)), tally); + } + + [Fact] + public async Task Anonymous_without_device_id_gets_no_identity_and_cast_is_refused() + { + await using var f = new Fixture(); + await f.AddSubject("ship-a"); + + Assert.Null(VoterIdentity.For(CurrentBifrostActor.Anonymous, null)); + Assert.Null(VoterIdentity.For(CurrentBifrostActor.Anonymous, "has:colon")); + Assert.Null(VoterIdentity.For(CurrentBifrostActor.Anonymous, string.Empty)); + Assert.Equal(CastResult.NoVoter, await f.Cast("ship-a", null, ConceptStance.Up)); + Assert.Empty(f.Db.ConceptAppraisals); + } + + [Theory] + [InlineData("abc\n")] + [InlineData("abc\r\n")] + [InlineData("abc ")] + [InlineData("abc\t")] + [InlineData("abc\0")] + public async Task A_device_id_with_trailing_whitespace_or_control_char_cannot_cast_beside_its_bare_form(string forged) + { + await using var f = new Fixture(); + await f.AddSubject("ship-a"); + + Assert.Equal(CastResult.Recorded, await f.Cast("ship-a", f.Anonymous("abc"), ConceptStance.Up)); + + Assert.Null(VoterIdentity.For(CurrentBifrostActor.Anonymous, forged)); + Assert.Equal(CastResult.NoVoter, await f.Cast("ship-a", VoterIdentity.For(CurrentBifrostActor.Anonymous, forged), ConceptStance.Down)); + Assert.Single(f.Db.ConceptAppraisals); + } + + [Fact] + public async Task A_changed_stance_is_saved_and_read_back_through_a_fresh_context() + { + var dbName = $"concept-{Guid.NewGuid():N}"; + await using var first = new Fixture(dbName); + await first.AddSubject("ship-a"); + Assert.Equal(CastResult.Recorded, await first.Cast("ship-a", first.Anonymous("device-one"), ConceptStance.Up)); + var other = IPAddress.Parse("198.51.100.9"); + + // Production scopes one DbContext per request: the recast gets a context that has never tracked the row. + await using (var second = new Fixture(dbName)) + { + second.Clock.Advance(TimeSpan.FromMinutes(5)); + Assert.Equal( + CastResult.Changed, + await second.Cast("ship-a", second.Anonymous("device-one"), ConceptStance.Down, other)); + } + + await using var reader = new Fixture(dbName); + var row = Assert.Single(reader.Db.ConceptAppraisals); + Assert.Equal(ConceptStance.Down, row.Stance); + Assert.Equal(NetworkPrefix.Tag(other, 32, 56, Fixture.Secret), row.NetworkTag); + Assert.Equal(new DateTimeOffset(2026, 10, 10, 12, 5, 0, TimeSpan.Zero), row.CastAtUtc); + Assert.Equal(new ConceptTally(0, 1, new StanceCounts(0, 0)), (await reader.Service.GetTalliesAsync(Collection, default))["ship-a"]); + } + + [Fact] + public async Task Voter_class_is_stamped_at_cast() + { + await using var f = new Fixture(); + await f.AddSubject("ship-a"); + var account = f.Account(MembershipStatus.Authenticated); + var member = f.Account(MembershipStatus.Active); + + await f.Cast("ship-a", f.Anonymous("device-one"), ConceptStance.Up); + await f.Cast("ship-a", VoterIdentity.For(account, null), ConceptStance.Up); + await f.Cast("ship-a", VoterIdentity.For(member, null), ConceptStance.Up); + + var rows = f.Db.ConceptAppraisals.ToList(); + Assert.Equal(VoterClass.Anonymous, rows.Single(x => x.VoterKey == "device:device-one").VoterClass); + Assert.Null(rows.Single(x => x.VoterKey == "device:device-one").UserAccountId); + var accountRow = rows.Single(x => x.UserAccountId == account.UserAccount!.Id); + var memberRow = rows.Single(x => x.UserAccountId == member.UserAccount!.Id); + Assert.Equal(VoterClass.Account, accountRow.VoterClass); + Assert.Equal(VoterClass.Member, memberRow.VoterClass); + + // Membership ends; the same account votes again with the opposite stance. The row keeps its stamp. + member.UserAccount!.Membership!.Status = MembershipStatus.Authenticated; + var recast = await f.Cast("ship-a", VoterIdentity.For(member, null), ConceptStance.Down); + + Assert.Equal(CastResult.Changed, recast); + Assert.Equal(VoterClass.Member, f.Db.ConceptAppraisals.Single(x => x.Id == memberRow.Id).VoterClass); + } + + [Fact] + public async Task Second_cast_by_the_same_key_changes_stance_without_a_new_row() + { + await using var f = new Fixture(); + await f.AddSubject("ship-a"); + var voter = f.Anonymous("device-one"); + + Assert.Equal(CastResult.Recorded, await f.Cast("ship-a", voter, ConceptStance.Up)); + Assert.Equal(CastResult.Changed, await f.Cast("ship-a", voter, ConceptStance.Down)); + Assert.Equal(CastResult.Unchanged, await f.Cast("ship-a", voter, ConceptStance.Down)); + + var row = Assert.Single(f.Db.ConceptAppraisals); + Assert.Equal(ConceptStance.Down, row.Stance); + } + + [Fact] + public async Task Member_weight_does_not_weight_counts() + { + await using var f = new Fixture(); + await f.AddSubject("ship-a"); + var member = f.Account(MembershipStatus.Active, weight: 5m); + Assert.Equal(5m, member.EffectiveVotingWeight); + + await f.Cast("ship-a", VoterIdentity.For(member, null), ConceptStance.Up); + + var tally = (await f.Service.GetTalliesAsync(Collection, default))["ship-a"]; + Assert.Equal(1, tally.Up); + Assert.Equal(1, tally.Members.Up); + } + + [Fact] + public async Task Member_tally_is_a_filter_over_the_same_rows() + { + await using var f = new Fixture(); + await f.AddSubject("ship-a"); + await f.Cast("ship-a", f.Anonymous("device-one"), ConceptStance.Up); + await f.Cast("ship-a", f.Anonymous("device-two"), ConceptStance.Down); + await f.Cast("ship-a", VoterIdentity.For(f.Account(MembershipStatus.Authenticated), null), ConceptStance.Up); + await f.Cast("ship-a", VoterIdentity.For(f.Account(MembershipStatus.Active), null), ConceptStance.Up); + await f.Cast("ship-a", VoterIdentity.For(f.Account(MembershipStatus.Active), null), ConceptStance.Down); + + var tally = (await f.Service.GetTalliesAsync(Collection, default))["ship-a"]; + var rows = f.Db.ConceptAppraisals.ToList(); + + Assert.Equal(rows.Count(x => x.Stance == ConceptStance.Up), tally.Up); + Assert.Equal(rows.Count(x => x.Stance == ConceptStance.Down), tally.Down); + Assert.Equal(rows.Count(x => x.Stance == ConceptStance.Up && x.VoterClass == VoterClass.Member), tally.Members.Up); + Assert.Equal(rows.Count(x => x.Stance == ConceptStance.Down && x.VoterClass == VoterClass.Member), tally.Members.Down); + Assert.Equal(new ConceptTally(3, 2, new StanceCounts(1, 1)), tally); + } + + [Fact] + public async Task Tally_carries_no_voter_identity() + { + await using var f = new Fixture(); + await f.AddSubject("ship-a"); + var account = f.Account(MembershipStatus.Authenticated); + var member = f.Account(MembershipStatus.Active); + await f.Cast("ship-a", f.Anonymous("device-secret-id"), ConceptStance.Up); + await f.Cast("ship-a", VoterIdentity.For(account, null), ConceptStance.Up); + await f.Cast("ship-a", VoterIdentity.For(member, null), ConceptStance.Down); + + Assert.Equal( + new[] { "Down", "Members", "Up" }, + typeof(ConceptTally).GetProperties(BindingFlags.Public | BindingFlags.Instance) + .Select(p => p.Name).Order().ToArray()); + Assert.Equal( + new[] { "Down", "Up" }, + typeof(StanceCounts).GetProperties(BindingFlags.Public | BindingFlags.Instance) + .Select(p => p.Name).Order().ToArray()); + + var json = JsonSerializer.Serialize(await f.Service.GetTalliesAsync(Collection, default)); + var view = JsonSerializer.Serialize( + await f.Service.GetSubjectAsync(Collection, "ship-a", VoterIdentity.For(account, null), default)); + foreach (var text in new[] { json, view }) + { + Assert.DoesNotContain("device:", text); + Assert.DoesNotContain("account:", text); + Assert.DoesNotContain("device-secret-id", text); + foreach (var id in new[] { account.UserAccount!.Id, member.UserAccount!.Id }) + { + Assert.DoesNotContain(id.ToString("D"), text); + Assert.DoesNotContain(id.ToString("N"), text); + } + + foreach (var tag in f.Db.ConceptAppraisals.Select(x => x.NetworkTag).Distinct()) + { + Assert.DoesNotContain(tag, text); + } + } + } + + [Fact] + public async Task Votes_on_unknown_or_retired_keys_are_refused_and_create_nothing() + { + await using var f = new Fixture(); + await f.AddSubject("ship-a"); + await f.AddSubject("ship-old", retired: true); + var subjectsBefore = f.Db.ConceptSubjects.Count(); + var voter = f.Anonymous("device-one"); + + Assert.Equal(CastResult.UnknownSubject, await f.Cast("ship-missing", voter, ConceptStance.Up)); + Assert.Equal(CastResult.UnknownSubject, await f.Cast("Ship_A", voter, ConceptStance.Up)); + Assert.Equal(CastResult.RetiredSubject, await f.Cast("ship-old", voter, ConceptStance.Up)); + + Assert.Equal(subjectsBefore, f.Db.ConceptSubjects.Count()); + Assert.Empty(f.Db.ConceptAppraisals); + Assert.DoesNotContain("ship-old", (await f.Service.GetTalliesAsync(Collection, default)).Keys); + Assert.Contains("ship-a", (await f.Service.GetTalliesAsync(Collection, default)).Keys); + } + + [Fact] + public async Task Retract_removes_the_row_and_the_count() + { + await using var f = new Fixture(); + await f.AddSubject("ship-a"); + var voter = f.Anonymous("device-one"); + await f.Cast("ship-a", voter, ConceptStance.Up); + await f.Cast("ship-a", f.Anonymous("device-two"), ConceptStance.Up); + + Assert.True(await f.Service.RetractAsync(Collection, "ship-a", voter, default)); + Assert.False(await f.Service.RetractAsync(Collection, "ship-a", voter, default)); + + Assert.Equal(1, (await f.Service.GetTalliesAsync(Collection, default))["ship-a"].Up); + Assert.Single(f.Db.ConceptAppraisals); + } + + [Fact] + public async Task Network_tag_is_an_hmac_never_a_raw_address() + { + await using var f = new Fixture(); + await f.AddSubject("ship-a"); + await f.Cast("ship-a", f.Anonymous("device-one"), ConceptStance.Up); + + var tag = f.Db.ConceptAppraisals.Single().NetworkTag; + + Assert.Matches("^[0-9a-f]{64}$", tag); + Assert.Equal(NetworkPrefix.Tag(Client, 32, 56, Fixture.Secret), tag); + } + + [Fact] + public async Task Unique_violation_at_insert_becomes_a_change() + { + var dbName = $"race-{Guid.NewGuid():N}"; + var racer = new RacingInterceptor(dbName); + await using var f = new Fixture(dbName, racer); + await f.AddSubject("ship-a"); + racer.Subject = f.Db.ConceptSubjects.Single(); + + var result = await f.Cast("ship-a", f.Anonymous("device-one"), ConceptStance.Down); + + Assert.Equal(CastResult.Changed, result); + Assert.Equal(1, racer.Fired); + var row = Assert.Single(f.Db.ConceptAppraisals); + Assert.Equal(ConceptStance.Down, row.Stance); + } + + [Fact] + public void Unique_indexes_are_declared_in_model_and_migration() + { + var options = new DbContextOptionsBuilder().UseNpgsql("Host=unused").Options; + using var db = new BifrostDbContext(options); + + Assert.Contains( + db.Model.FindEntityType(typeof(ConceptAppraisal))!.GetIndexes(), + i => i.IsUnique && i.Properties.Select(p => p.Name).SequenceEqual(["SubjectId", "VoterKey"])); + Assert.Contains( + db.Model.FindEntityType(typeof(ConceptSubject))!.GetIndexes(), + i => i.IsUnique && i.Properties.Select(p => p.Name).SequenceEqual(["Collection", "Key"])); + + var script = db.Database.GenerateCreateScript(); + Assert.Matches("CREATE UNIQUE INDEX \"IX_ConceptAppraisals_SubjectId_VoterKey\"", script); + Assert.Matches("CREATE UNIQUE INDEX \"IX_ConceptSubjects_Collection_Key\"", script); + + } + + [Fact] + public async Task Purge_removes_only_rows_in_tag_and_window_and_audits() + { + await using var f = new Fixture(); + await f.AddSubject("ship-a"); + var admin = f.Admin(); + var t0 = f.Clock.GetUtcNow(); + var other = IPAddress.Parse("198.51.100.9"); + + await f.Cast("ship-a", f.Anonymous("device-in"), ConceptStance.Up); + await f.Cast("ship-a", f.Anonymous("device-other-net"), ConceptStance.Up, other); + f.Clock.Advance(TimeSpan.FromHours(2)); + await f.Cast("ship-a", f.Anonymous("device-late"), ConceptStance.Up); + var tag = NetworkPrefix.Tag(Client, 32, 56, Fixture.Secret); + + var removed = await f.Service.PurgeAsync(admin, tag, t0, t0.AddHours(1), default); + + Assert.Equal(1, removed); + Assert.Equal( + ["device:device-late", "device:device-other-net"], + f.Db.ConceptAppraisals.Select(x => x.VoterKey).ToList().Order().ToArray()); + var audit = Assert.Single(f.Db.AuditEvents); + Assert.Equal("ConceptAppraisal", audit.EntityType); + Assert.Equal("Purge", audit.Action); + Assert.DoesNotContain(tag, audit.Detail); + Assert.DoesNotContain("tag", audit.Detail); + Assert.Equal(admin.UserAccount!.Id, audit.ActorUserAccountId); + Assert.Contains("from=" + t0.ToString("O"), audit.Detail); + Assert.Contains("count=1", audit.Detail); + Assert.DoesNotContain("device", audit.Detail); + + var refused = await Assert.ThrowsAsync( + () => f.Service.PurgeAsync(f.Account(MembershipStatus.Active), tag, t0, t0.AddDays(1), default)); + Assert.DoesNotContain(tag, refused.Message); + Assert.Equal("Only a platform administrator can purge appraisals.", refused.Message); + Assert.Equal(2, f.Db.ConceptAppraisals.Count()); + Assert.Single(f.Db.AuditEvents); + } + + [Fact] + public async Task Unique_violation_with_no_surviving_row_is_not_swallowed() + { + var dbName = $"race-{Guid.NewGuid():N}"; + var racer = new RacingInterceptor(dbName) { InsertRival = false }; + await using var f = new Fixture(dbName, racer); + await f.AddSubject("ship-a"); + racer.Subject = f.Db.ConceptSubjects.Single(); + + await Assert.ThrowsAsync( + () => f.Cast("ship-a", f.Anonymous("device-one"), ConceptStance.Up)); + } + + [Fact] + public async Task A_changed_cast_restamps_tag_and_time() + { + await using var f = new Fixture(); + await f.AddSubject("ship-a"); + var voter = f.Anonymous("device-one"); + await f.Cast("ship-a", voter, ConceptStance.Up, IPAddress.Parse("198.51.100.9")); + var first = f.Db.ConceptAppraisals.Single().CastAtUtc; + f.Clock.Advance(TimeSpan.FromMinutes(5)); + + await f.Cast("ship-a", voter, ConceptStance.Down); + + var row = f.Db.ConceptAppraisals.Single(); + Assert.Equal(NetworkPrefix.Tag(Client, 32, 56, Fixture.Secret), row.NetworkTag); + Assert.Equal(first.AddMinutes(5), row.CastAtUtc); + } + + [Fact] + public async Task Unknown_client_address_leaves_no_tag_and_purge_never_matches_the_empty_tag() + { + await using var f = new Fixture(); + await f.AddSubject("ship-a"); + await f.Service.CastAsync(Collection, "ship-a", f.Anonymous("device-one"), ConceptStance.Up, null, default); + + Assert.Equal(string.Empty, f.Db.ConceptAppraisals.Single().NetworkTag); + var t0 = f.Clock.GetUtcNow(); + Assert.Equal(0, await f.Service.PurgeAsync(f.Admin(), string.Empty, t0.AddDays(-1), t0.AddDays(1), default)); + Assert.Single(f.Db.ConceptAppraisals); + Assert.Empty(f.Db.AuditEvents); + } + + [Fact] + public async Task Purge_window_excludes_its_end_and_includes_its_start() + { + await using var f = new Fixture(); + await f.AddSubject("ship-a"); + var t0 = f.Clock.GetUtcNow(); + await f.Cast("ship-a", f.Anonymous("device-one"), ConceptStance.Up); + var tag = NetworkPrefix.Tag(Client, 32, 56, Fixture.Secret); + + Assert.Equal(0, await f.Service.PurgeAsync(f.Admin(), tag, t0.AddDays(-1), t0, default)); + Assert.Equal(1, await f.Service.PurgeAsync(f.Admin(), tag, t0, t0.AddSeconds(1), default)); + } + + [Fact] + public async Task Retract_by_no_voter_or_on_unknown_subject_is_a_no_op() + { + await using var f = new Fixture(); + await f.AddSubject("ship-a"); + var voter = f.Anonymous("device-one"); + await f.Cast("ship-a", voter, ConceptStance.Up); + + Assert.False(await f.Service.RetractAsync(Collection, "ship-a", null, default)); + Assert.False(await f.Service.RetractAsync(Collection, "ship-missing", voter, default)); + Assert.False(await f.Service.RetractAsync("other-collection", "ship-a", voter, default)); + Assert.Single(f.Db.ConceptAppraisals); + } + + [Fact] + public async Task Subject_page_view_counts_only_its_own_subject_and_shows_the_voters_own_stance() + { + await using var f = new Fixture(); + await f.AddSubject("ship-a"); + await f.AddSubject("ship-b"); + await f.AddSubject("ship-old", retired: true); + var mine = f.Anonymous("device-one"); + await f.Cast("ship-a", mine, ConceptStance.Down); + await f.Cast("ship-a", VoterIdentity.For(f.Account(MembershipStatus.Active), null), ConceptStance.Up); + await f.Cast("ship-a", VoterIdentity.For(f.Account(MembershipStatus.Authenticated), null), ConceptStance.Up); + await f.Cast("ship-b", f.Anonymous("device-two"), ConceptStance.Up); + + var view = await f.Service.GetSubjectAsync(Collection, "ship-a", mine, default); + var stranger = await f.Service.GetSubjectAsync(Collection, "ship-a", f.Anonymous("device-nine"), default); + var anon = await f.Service.GetSubjectAsync(Collection, "ship-a", null, default); + + Assert.Equal(new ConceptTally(2, 1, new StanceCounts(1, 0)), view!.Tally); + Assert.Equal(ConceptStance.Down, view.YourStance); + Assert.Null(stranger!.YourStance); + Assert.Null(anon!.YourStance); + Assert.Null(await f.Service.GetSubjectAsync(Collection, "ship-old", mine, default)); + Assert.Null(await f.Service.GetSubjectAsync(Collection, "ship-missing", mine, default)); + } + + [Fact] + public async Task Collection_and_key_must_both_be_slugs_even_when_a_row_exists() + { + await using var f = new Fixture(); + await f.AddSubject("Ship_A"); + f.Db.ConceptSubjects.Add(new ConceptSubject + { + Collection = "Bad_Collection", + Key = "ship-a", + Title = "t", + RegisteredAtUtc = f.Clock.GetUtcNow() + }); + await f.Db.SaveChangesAsync(); + var voter = f.Anonymous("device-one"); + + Assert.Equal(CastResult.UnknownSubject, await f.Cast("Ship_A", voter, ConceptStance.Up)); + Assert.Equal( + CastResult.UnknownSubject, + await f.Service.CastAsync("Bad_Collection", "ship-a", voter, ConceptStance.Up, Client, default)); + Assert.Empty(f.Db.ConceptAppraisals); + } + + [Fact] + public async Task Voter_keys_have_their_documented_shape() + { + await using var f = new Fixture(); + var account = f.Account(MembershipStatus.Authenticated); + + Assert.Equal("device:abc-DEF_123", VoterIdentity.For(CurrentBifrostActor.Anonymous, "abc-DEF_123")!.Key); + Assert.Equal("account:" + account.UserAccount!.Id.ToString("N"), VoterIdentity.For(account, "ignored")!.Key); + Assert.Null(VoterIdentity.For(CurrentBifrostActor.Anonymous, new string('a', 101))); + Assert.NotNull(VoterIdentity.For(CurrentBifrostActor.Anonymous, new string('a', 100))); + } + + private sealed class RacingInterceptor(string dbName) : SaveChangesInterceptor + { + public bool InsertRival { get; init; } = true; + + public ConceptSubject? Subject { get; set; } + + public int Fired { get; private set; } + + public override async ValueTask> SavingChangesAsync( + DbContextEventData eventData, + InterceptionResult result, + CancellationToken cancellationToken = default) + { + var adding = eventData.Context!.ChangeTracker.Entries() + .Any(e => e.State == EntityState.Added); + if (Subject is null || !adding || Fired > 0) + { + return result; + } + + Fired++; + if (!InsertRival) + { + throw new DbUpdateException("conflict", new PostgresException("duplicate", "ERROR", "ERROR", "23505")); + } + + var options = new DbContextOptionsBuilder().UseInMemoryDatabase(dbName).Options; + await using (var other = new BifrostDbContext(options)) + { + other.ConceptAppraisals.Add(new ConceptAppraisal + { + SubjectId = Subject.Id, + VoterKey = "device:device-one", + Stance = ConceptStance.Up, + VoterClass = VoterClass.Anonymous, + CastAtUtc = DateTimeOffset.UtcNow + }); + await other.SaveChangesAsync(cancellationToken); + } + + throw new DbUpdateException( + "conflict", + new PostgresException("duplicate", "ERROR", "ERROR", "23505")); + } + } + + private sealed class Fixture : IAsyncDisposable + { + public const string Secret = "test-tag-secret"; + + public Fixture(string? dbName = null, IInterceptor? interceptor = null) + { + var builder = new DbContextOptionsBuilder() + .UseInMemoryDatabase(dbName ?? $"concept-{Guid.NewGuid():N}"); + if (interceptor is not null) + { + builder.AddInterceptors(interceptor); + } + + Db = new BifrostDbContext(builder.Options); + Clock = new ManualClock(new DateTimeOffset(2026, 10, 10, 12, 0, 0, TimeSpan.Zero)); + Service = new ConceptAppraisalService( + Db, + new AuditTrailService(Db, Clock), + Clock, + Options.Create(new ConceptVoteOptions { NetworkTagSecret = Secret })); + } + + public BifrostDbContext Db { get; } + + public ManualClock Clock { get; } + + public ConceptAppraisalService Service { get; } + + public async Task AddSubject(string key, bool retired = false) + { + Db.ConceptSubjects.Add(new ConceptSubject + { + Collection = Collection, + Key = key, + Title = key, + RegisteredAtUtc = Clock.GetUtcNow(), + RetiredAtUtc = retired ? Clock.GetUtcNow() : null + }); + await Db.SaveChangesAsync(); + } + + public Task Cast(string key, VoterIdentity? voter, ConceptStance stance, IPAddress? client = null) => + Service.CastAsync(Collection, key, voter, stance, client ?? Client, default); + + public VoterIdentity Anonymous(string deviceId) => + VoterIdentity.For(CurrentBifrostActor.Anonymous, deviceId)!; + + public CurrentBifrostActor Account(MembershipStatus status, decimal weight = 0m, bool admin = false) + { + var user = new UserAccount + { + DisplayName = "voter", + GitHubLogin = "voter", + NormalizedGitHubLogin = "voter", + CreatedAtUtc = Clock.GetUtcNow(), + LastSeenAtUtc = Clock.GetUtcNow() + }; + user.Membership = new Membership + { + UserAccountId = user.Id, + Status = status, + IsPlatformAdmin = admin, + CreatedAtUtc = Clock.GetUtcNow() + }; + if (weight > 0m) + { + user.Membership.TierSnapshots.Add(new TierSnapshot { Weight = weight, IsCurrent = true }); + } + + Db.UserAccounts.Add(user); + Db.SaveChanges(); + return new CurrentBifrostActor(true, user); + } + + public CurrentBifrostActor Admin() + { + return Account(MembershipStatus.Active, admin: true); + } + + public ValueTask DisposeAsync() => Db.DisposeAsync(); + } + + private sealed class ManualClock(DateTimeOffset start) : TimeProvider + { + private DateTimeOffset _now = start; + + public override DateTimeOffset GetUtcNow() => _now; + + public void Advance(TimeSpan by) => _now += by; + } +} diff --git a/tests/Bifrost.Web.Tests/HostTrustTests.cs b/tests/Bifrost.Web.Tests/HostTrustTests.cs index 78f9057..21ba350 100644 --- a/tests/Bifrost.Web.Tests/HostTrustTests.cs +++ b/tests/Bifrost.Web.Tests/HostTrustTests.cs @@ -146,9 +146,49 @@ public sealed class HostTrustTests : IClassFixture } [Fact] - public void Development_does_not_require_keys_path() + public void Production_without_network_tag_secret_fails_and_never_echoes_values() { - Validate(keysPath: null, environment: Environments.Development); + var canary = "canary-keys-path-77aa"; + var refusal = Assert.Throws( + () => Validate(keysPath: canary, tagSecret: " ")); + + Assert.Contains("ConceptVotes:NetworkTagSecret", refusal.Message); + Assert.DoesNotContain(canary, refusal.Message); + Assert.DoesNotContain("DataProtection:KeysPath", refusal.Message); + } + + [Theory] + [InlineData("short-secret")] + [InlineData("0123456789012345678901234567890")] + public void Production_refuses_a_short_network_tag_secret_without_echoing_it(string secret) + { + var refusal = Assert.Throws( + () => Validate(keysPath: "/keys", tagSecret: secret)); + + Assert.Contains("ConceptVotes:NetworkTagSecret", refusal.Message); + Assert.DoesNotContain(secret, refusal.Message); + Validate(keysPath: "/keys", tagSecret: "01234567890123456789012345678901"); + } + + [Theory] + [InlineData(33, 56, "ConceptVotes:IPv4PrefixLength")] + [InlineData(-1, 56, "ConceptVotes:IPv4PrefixLength")] + [InlineData(32, 129, "ConceptVotes:IPv6PrefixLength")] + [InlineData(32, -1, "ConceptVotes:IPv6PrefixLength")] + public void Production_refuses_prefix_lengths_outside_the_address_family(int v4, int v6, string key) + { + var refusal = Assert.Throws( + () => Validate(keysPath: "/keys", ipv4Length: v4, ipv6Length: v6)); + + Assert.Contains(key, refusal.Message); + Validate(keysPath: "/keys", ipv4Length: 32, ipv6Length: 128); + Validate(keysPath: "/keys", ipv4Length: 0, ipv6Length: 0); + } + + [Fact] + public void Development_does_not_require_keys_path_or_tag_secret() + { + Validate(keysPath: null, environment: Environments.Development, tagSecret: null); } private async Task RunForwardedHeaders(string peer, string forwardedFor, string? forwardedProto) @@ -196,16 +236,21 @@ public sealed class HostTrustTests : IClassFixture } } + private const string ValidTagSecret = "tag-secret-0123456789-0123456789-abcdef"; + private static void Validate( string? keysPath, string environment = "Production", - string? connectionString = "Host=db") + string? connectionString = "Host=db", + string? tagSecret = ValidTagSecret, + int ipv4Length = 32, + int ipv6Length = 56) { var values = new Dictionary { ["AllowedHosts"] = "bifrost.example", ["ConnectionStrings:Bifrost"] = connectionString, - ["DataProtection:KeysPath"] = keysPath + ["DataProtection:KeysPath"] = keysPath, }; var configuration = new ConfigurationBuilder().AddInMemoryCollection(values).Build(); var hostEnvironment = new StubHostEnvironment(environment); @@ -221,6 +266,12 @@ public sealed class HostTrustTests : IClassFixture { PublicBaseUrl = "https://bifrost.example", ExpectedHost = "bifrost.example" + }), + Options.Create(new ConceptVoteOptions + { + NetworkTagSecret = tagSecret ?? string.Empty, + IPv4PrefixLength = ipv4Length, + IPv6PrefixLength = ipv6Length })).Validate(); } diff --git a/tests/Bifrost.Web.Tests/MemberConsoleTests.cs b/tests/Bifrost.Web.Tests/MemberConsoleTests.cs index a3addd0..e4e1804 100644 --- a/tests/Bifrost.Web.Tests/MemberConsoleTests.cs +++ b/tests/Bifrost.Web.Tests/MemberConsoleTests.cs @@ -1,4 +1,9 @@ using System.Net; +using Bifrost.Web.Configuration; +using Bifrost.Web.Features.ConceptVotes; +using Bifrost.Web.Features.Shared; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Options; using Bifrost.Web.Data; using Bifrost.Web.Domain; using Bifrost.Web.Tests.Support; @@ -143,4 +148,51 @@ public sealed class MemberConsoleTests : IClassFixture(); + var admin = await dbContext.UserAccounts + .Include(x => x.Membership) + .SingleAsync(x => x.NormalizedGitHubLogin == "TEST-ADMIN"); + var now = DateTimeOffset.UtcNow; + dbContext.ConceptSubjects.Add(new ConceptSubject + { + Collection = "aetheria-concept-mill", + Key = "ship-a", + Title = "ship-a", + RegisteredAtUtc = now + }); + await dbContext.SaveChangesAsync(); + + var service = scope.ServiceProvider.GetRequiredService(); + var options = scope.ServiceProvider.GetRequiredService>().Value; + var client = System.Net.IPAddress.Parse("203.0.113.7"); + tag = NetworkPrefix.Tag(client, options.IPv4PrefixLength, options.IPv6PrefixLength, options.NetworkTagSecret); + var voter = VoterIdentity.For(CurrentBifrostActor.Anonymous, "member-view-device")!; + await service.CastAsync("aetheria-concept-mill", "ship-a", voter, ConceptStance.Up, client, default); + + var removed = await service.PurgeAsync( + new CurrentBifrostActor(true, admin), tag, now.AddHours(-1), now.AddHours(1), default); + Assert.Equal(1, removed); + } + + using var memberClient = _factory.CreateClient(); + using var response = await memberClient.GetAsync("/App"); + var html = await response.Content.ReadAsStringAsync(); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Contains("Purge", html); + Assert.Contains("count", html); + Assert.DoesNotContain(tag, html); + } } diff --git a/tests/Bifrost.Web.Tests/MigrationDiscoveryTests.cs b/tests/Bifrost.Web.Tests/MigrationDiscoveryTests.cs index 8d81199..f4b1077 100644 --- a/tests/Bifrost.Web.Tests/MigrationDiscoveryTests.cs +++ b/tests/Bifrost.Web.Tests/MigrationDiscoveryTests.cs @@ -20,6 +20,7 @@ public sealed class MigrationDiscoveryTests "20260622165000_AddBifrostNativeIdentity", "20260622202000_AddBridgeActionTargetSurfaceName", "20260705190000_AddPatronSupportProjectAttribution", + "20261010043937_AddConceptVotes", ]; // Database.GetMigrations() reads assembly metadata only; no connection is opened. @@ -49,7 +50,7 @@ public sealed class MigrationDiscoveryTests } [Fact] - public void All_twelve_migrations_are_listed_in_order() + public void All_thirteen_migrations_are_listed_in_order() { Assert.Equal(ExpectedMigrations, Discovered()); } diff --git a/tests/Bifrost.Web.Tests/NetworkPrefixTests.cs b/tests/Bifrost.Web.Tests/NetworkPrefixTests.cs new file mode 100644 index 0000000..79af76b --- /dev/null +++ b/tests/Bifrost.Web.Tests/NetworkPrefixTests.cs @@ -0,0 +1,64 @@ +using System.Net; +using Bifrost.Web.Features.ConceptVotes; + +namespace Bifrost.Web.Tests; + +public sealed class NetworkPrefixTests +{ + private const string Secret = "prefix-secret"; + + private static string Tag(string address, int v4 = 24, int v6 = 56, string secret = Secret) => + NetworkPrefix.Tag(IPAddress.Parse(address), v4, v6, secret); + + [Fact] + public void Two_addresses_inside_one_prefix_share_a_tag_and_other_prefixes_do_not() + { + Assert.Equal(Tag("203.0.113.7"), Tag("203.0.113.200")); + Assert.NotEqual(Tag("203.0.113.7"), Tag("203.0.114.7")); + Assert.Equal(Tag("2001:db8:1:ff::1"), Tag("2001:db8:1:ff:aaaa::9")); + Assert.NotEqual(Tag("2001:db8:1:ff::1"), Tag("2001:db8:2:ff::1")); + Assert.Equal("203.0.113.0/24", NetworkPrefix.Of(IPAddress.Parse("203.0.113.7"), 24, 56)); + Assert.Equal("2001:db8:1:ff00::/56", NetworkPrefix.Of(IPAddress.Parse("2001:db8:1:ffee::1"), 24, 56)); + } + + [Fact] + public void Prefix_length_is_the_configured_one() + { + Assert.NotEqual(Tag("203.0.113.7", v4: 32), Tag("203.0.113.8", v4: 32)); + Assert.Equal(Tag("2001:db8:1:ff::1", v6: 48), Tag("2001:db8:1:aa::1", v6: 48)); + } + + [Fact] + public void IPv4_mapped_IPv6_equals_the_IPv4_address() + { + Assert.Equal(Tag("203.0.113.7"), Tag("::ffff:203.0.113.7")); + } + + [Fact] + public void A_different_secret_gives_a_different_tag_and_the_tag_is_lowercase_hex() + { + var tag = Tag("203.0.113.7"); + + Assert.NotEqual(tag, Tag("203.0.113.7", secret: "other-secret")); + Assert.Matches("^[0-9a-f]{64}$", tag); + } + + [Fact] + public void A_length_outside_the_family_is_refused_without_echoing_the_address() + { + var ex = Assert.Throws( + () => NetworkPrefix.Of(IPAddress.Parse("203.0.113.7"), 33, 56)); + + Assert.DoesNotContain("203.0.113.7", ex.Message); + Assert.Contains("Prefix length", ex.Message); + } + + [Fact] + public void Masking_is_exact_inside_a_byte_and_a_zero_length_is_the_whole_family() + { + Assert.Equal("203.0.112.0/20", NetworkPrefix.Of(IPAddress.Parse("203.0.127.200"), 20, 56)); + Assert.Equal("2001:db8:1:f0::/60", NetworkPrefix.Of(IPAddress.Parse("2001:db8:1:ff::1"), 24, 60)); + Assert.Equal("0.0.0.0/0", NetworkPrefix.Of(IPAddress.Parse("203.0.113.7"), 0, 56)); + Assert.Equal("203.0.113.7/32", NetworkPrefix.Of(IPAddress.Parse("203.0.113.7"), 32, 56)); + } +}