From 5d5aca05a80235ccddf3f2fe9591e901476c4022 Mon Sep 17 00:00:00 2001 From: Stef Kariotidis Date: Sun, 23 Aug 2026 12:12:35 +0300 Subject: [PATCH 01/13] WIP: cover protected provider credentials and budgets (checkpoint; tests failing) --- ...PersonalLocationProviderFoundationTests.cs | 79 +++++++++++++++++++ 1 file changed, 79 insertions(+) create mode 100644 tests/Wayfarer.Tests/Services/PersonalLocationProviderFoundationTests.cs diff --git a/tests/Wayfarer.Tests/Services/PersonalLocationProviderFoundationTests.cs b/tests/Wayfarer.Tests/Services/PersonalLocationProviderFoundationTests.cs new file mode 100644 index 00000000..6c6dfc92 --- /dev/null +++ b/tests/Wayfarer.Tests/Services/PersonalLocationProviderFoundationTests.cs @@ -0,0 +1,79 @@ +using Microsoft.AspNetCore.DataProtection; +using Wayfarer.Models.LocationProviders; +using Wayfarer.Services.LocationProviders; +using Xunit; + +namespace Wayfarer.Tests.Services; + +/// Defines the shared personal-provider authority required by issues 500 through 502. +public sealed class PersonalLocationProviderFoundationTests +{ + [Fact] + public void CredentialOwner_ProtectsProviderProfileCredential() + { + var profile = PersonalLocationProviderProfile.Create("user-1", PersonalLocationProvider.Mapbox); + var owner = new PersonalProviderCredentialService(new EphemeralDataProtectionProvider()); + + owner.Replace(profile, "secret-mapbox-key"); + + Assert.DoesNotContain("secret-mapbox-key", profile.ProtectedCredential, StringComparison.Ordinal); + Assert.Equal("secret-mapbox-key", owner.Read(profile).Credential); + } + + [Fact] + public void Profile_AuthorizesGeocodingAndRoutingIndependently() + { + var profile = PersonalLocationProviderProfile.Create("user-1", PersonalLocationProvider.Geoapify); + + profile.SetAuthorization(PersonalProviderCapability.Geocoding, true); + + Assert.True(profile.IsAuthorized(PersonalProviderCapability.Geocoding)); + Assert.False(profile.IsAuthorized(PersonalProviderCapability.Routing)); + } + + [Fact] + public void SwitchingProvider_RetainsInactiveProfileCredential() + { + var mapbox = PersonalLocationProviderProfile.Create("user-1", PersonalLocationProvider.Mapbox); + var owner = new PersonalProviderCredentialService(new EphemeralDataProtectionProvider()); + owner.Replace(mapbox, "retained-key"); + var selection = PersonalLocationProviderSelection.Create("user-1"); + + selection.Select(PersonalProviderCapability.Geocoding, PersonalLocationProvider.Mapbox); + selection.Select(PersonalProviderCapability.Geocoding, PersonalLocationProvider.Geoapify); + + Assert.Equal("retained-key", owner.Read(mapbox).Credential); + } + + [Fact] + public void LegacyMigration_DoesNotRetireMapboxUntilProtectedReadbackSucceeds() + { + var decision = LegacyMapboxMigration.Decide( + protectedRead: PersonalCredentialRead.Unavailable, + recognizedLegacyValues: ["legacy-key"]); + + Assert.False(decision.RetireLegacy); + Assert.Equal(LegacyMapboxMigrationState.ProtectedCredentialUnavailable, decision.State); + } + + [Fact] + public void GeoapifyAdmission_UsesOneRollingSharedCreditPool() + { + var ledger = new PersonalProviderUsageLedger(); + var now = new DateTimeOffset(2026, 8, 23, 12, 0, 0, TimeSpan.Zero); + + Assert.True(ledger.TryAdmitGeoapify(now, 2_500, 2_499, PersonalProviderProduct.Geocoding)); + Assert.False(ledger.TryAdmitGeoapify(now, 2_500, 1, PersonalProviderProduct.Routing)); + } + + [Fact] + public void MapboxAdmission_UsesIndependentProductCounters() + { + var ledger = new PersonalProviderUsageLedger(); + var cycle = new DateOnly(2026, 8, 1); + + Assert.True(ledger.TryAdmitMapbox(cycle, PersonalProviderProduct.PermanentGeocoding, 1, 1)); + Assert.False(ledger.TryAdmitMapbox(cycle, PersonalProviderProduct.PermanentGeocoding, 1, 1)); + Assert.True(ledger.TryAdmitMapbox(cycle, PersonalProviderProduct.Directions, 1, 1)); + } +} From abb4e482bd399e0961ca141f7b29b5941db212dc Mon Sep 17 00:00:00 2001 From: Stef Kariotidis Date: Sun, 23 Aug 2026 12:17:23 +0300 Subject: [PATCH 02/13] feat(providers): protect personal provider profiles --- ...onalLocationProviderFoundation.Designer.cs | 2313 +++++++++++++++++ ...9_AddPersonalLocationProviderFoundation.cs | 178 ++ .../ApplicationDbContextModelSnapshot.cs | 265 +- Models/ApplicationDbContext.cs | 13 +- .../PersonalLocationProviderConfiguration.cs | 39 + .../PersonalProviderUsageConfiguration.cs | 50 + .../LegacyMapboxMigration.cs | 29 + .../PersonalLocationProviderProfile.cs | 100 + .../PersonalLocationProviderSelection.cs | 33 + .../PersonalProviderUsage.cs | 34 + Program.cs | 5 + .../DataProtectionAuthority.cs | 70 + .../PersonalProviderCredentialService.cs | 54 + .../PersonalProviderUsageLedger.cs | 39 + ...PersonalLocationProviderFoundationTests.cs | 2 +- 15 files changed, 3221 insertions(+), 3 deletions(-) create mode 100644 Migrations/20260823091629_AddPersonalLocationProviderFoundation.Designer.cs create mode 100644 Migrations/20260823091629_AddPersonalLocationProviderFoundation.cs create mode 100644 Models/Configuration/PersonalLocationProviderConfiguration.cs create mode 100644 Models/Configuration/PersonalProviderUsageConfiguration.cs create mode 100644 Models/LocationProviders/LegacyMapboxMigration.cs create mode 100644 Models/LocationProviders/PersonalLocationProviderProfile.cs create mode 100644 Models/LocationProviders/PersonalLocationProviderSelection.cs create mode 100644 Models/LocationProviders/PersonalProviderUsage.cs create mode 100644 Services/LocationProviders/DataProtectionAuthority.cs create mode 100644 Services/LocationProviders/PersonalProviderCredentialService.cs create mode 100644 Services/LocationProviders/PersonalProviderUsageLedger.cs diff --git a/Migrations/20260823091629_AddPersonalLocationProviderFoundation.Designer.cs b/Migrations/20260823091629_AddPersonalLocationProviderFoundation.Designer.cs new file mode 100644 index 00000000..6699ddd3 --- /dev/null +++ b/Migrations/20260823091629_AddPersonalLocationProviderFoundation.Designer.cs @@ -0,0 +1,2313 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using NetTopologySuite.Geometries; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using Wayfarer.Models; + +#nullable disable + +namespace Wayfarer.Migrations +{ + [DbContext(typeof(ApplicationDbContext))] + [Migration("20260823091629_AddPersonalLocationProviderFoundation")] + partial class AddPersonalLocationProviderFoundation + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.11") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "citext"); + NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "postgis"); + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("ApplicationSettings", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ActiveRoutingProviderConfigurationId") + .HasColumnType("uuid"); + + b.Property("ExternalRouteGenerationEnabled") + .HasColumnType("boolean"); + + b.Property("ExternalRouteGenerationVersion") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(1); + + b.Property("ImageCacheExpiryDays") + .HasColumnType("integer"); + + b.Property("IsRegistrationOpen") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("LocationAccuracyThresholdMeters") + .HasColumnType("integer"); + + b.Property("LocationDistanceThresholdMeters") + .HasColumnType("integer"); + + b.Property("LocationTimeThresholdMinutes") + .HasColumnType("integer"); + + b.Property("MaxCacheImageSizeInMB") + .HasColumnType("integer"); + + b.Property("MaxCacheTileSizeInMB") + .HasColumnType("integer"); + + b.Property("MaxProxyImageDownloadMB") + .HasColumnType("integer"); + + b.Property("ProxyImageRateLimitEnabled") + .HasColumnType("boolean"); + + b.Property("ProxyImageRateLimitPerMinute") + .HasColumnType("integer"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.Property("TileMetadataHotCacheSizeMB") + .HasColumnType("integer"); + + b.Property("TileOutboundBudgetHistorical30Acknowledged") + .HasColumnType("boolean"); + + b.Property("TileOutboundBudgetPerIpPerMinute") + .HasColumnType("integer"); + + b.Property("TileProviderAdvancedLimitsEnabled") + .HasColumnType("boolean"); + + b.Property("TileProviderApiKey") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("TileProviderAttribution") + .IsRequired() + .HasMaxLength(300) + .HasColumnType("character varying(300)"); + + b.Property("TileProviderBurstCapacity") + .HasColumnType("integer"); + + b.Property("TileProviderFallbackBaseDelayMs") + .HasColumnType("integer"); + + b.Property("TileProviderFallbackDelayCapSeconds") + .HasColumnType("integer"); + + b.Property("TileProviderKey") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("TileProviderMaxAttempts") + .HasColumnType("integer"); + + b.Property("TileProviderMaxConcurrency") + .HasColumnType("integer"); + + b.Property("TileProviderMaxIndividualWaitSeconds") + .HasColumnType("integer"); + + b.Property("TileProviderSustainedRequestsPerSecond") + .HasColumnType("integer"); + + b.Property("TileProviderTotalRetryCeilingSeconds") + .HasColumnType("integer"); + + b.Property("TileProviderUrlTemplate") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("TileRateLimitAuthenticatedPerMinute") + .HasColumnType("integer"); + + b.Property("TileRateLimitEnabled") + .HasColumnType("boolean"); + + b.Property("TileRateLimitPerMinute") + .HasColumnType("integer"); + + b.Property("TileTrafficMode") + .HasColumnType("integer"); + + b.Property("UploadSizeLimitMB") + .HasColumnType("integer"); + + b.Property("VisitNotificationCooldownHours") + .HasColumnType("integer"); + + b.Property("VisitedAccuracyMultiplier") + .HasColumnType("double precision"); + + b.Property("VisitedAccuracyRejectMeters") + .HasColumnType("integer"); + + b.Property("VisitedMaxRadiusMeters") + .HasColumnType("integer"); + + b.Property("VisitedMaxSearchRadiusMeters") + .HasColumnType("integer"); + + b.Property("VisitedMinRadiusMeters") + .HasColumnType("integer"); + + b.Property("VisitedPlaceNotesSnapshotMaxHtmlChars") + .HasColumnType("integer"); + + b.Property("VisitedRequiredHits") + .HasColumnType("integer"); + + b.Property("VisitedSuggestionMaxRadiusMultiplier") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ActiveRoutingProviderConfigurationId"); + + b.ToTable("ApplicationSettings"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("RoleId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("ProviderKey") + .HasColumnType("text"); + + b.Property("ProviderDisplayName") + .HasColumnType("text"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("text"); + + b.Property("RoleId") + .HasColumnType("text"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("text"); + + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("Name") + .HasColumnType("text"); + + b.Property("Value") + .HasColumnType("text"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("TripTags", b => + { + b.Property("TripId") + .HasColumnType("uuid"); + + b.Property("TagId") + .HasColumnType("uuid"); + + b.HasKey("TripId", "TagId"); + + b.HasIndex("TagId"); + + b.HasIndex("TripId"); + + b.ToTable("TripTags", (string)null); + }); + + modelBuilder.Entity("Wayfarer.Models.ActivityType", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("ActivityTypes"); + }); + + modelBuilder.Entity("Wayfarer.Models.ApiToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("Token") + .HasColumnType("text"); + + b.Property("TokenHash") + .HasColumnType("text"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.HasIndex("Name", "UserId") + .IsUnique() + .HasDatabaseName("IX_ApiToken_Name_UserId"); + + b.ToTable("ApiTokens"); + }); + + modelBuilder.Entity("Wayfarer.Models.ApplicationUser", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("AccessFailedCount") + .HasColumnType("integer"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("DisplayName") + .IsRequired() + .HasColumnType("text"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("boolean"); + + b.Property("IsActive") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true); + + b.Property("IsProtected") + .HasColumnType("boolean"); + + b.Property("IsTimelinePublic") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("LockoutEnabled") + .HasColumnType("boolean"); + + b.Property("LockoutEnd") + .HasColumnType("timestamp with time zone"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("PasswordHash") + .HasColumnType("text"); + + b.Property("PhoneNumber") + .HasColumnType("text"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("boolean"); + + b.Property("PublicTimelineTimeThreshold") + .HasColumnType("text"); + + b.Property("SecurityStamp") + .HasColumnType("text"); + + b.Property("TimelineTitle") + .HasMaxLength(80) + .HasColumnType("character varying(80)"); + + b.Property("TwoFactorEnabled") + .HasColumnType("boolean"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.HasIndex("UserName") + .IsUnique(); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("Wayfarer.Models.Area", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("DisplayOrder") + .HasColumnType("integer"); + + b.Property("FillHex") + .HasColumnType("text"); + + b.Property("Geometry") + .IsRequired() + .HasColumnType("geometry"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Notes") + .HasColumnType("text"); + + b.Property("RegionId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("RegionId"); + + b.ToTable("Areas"); + }); + + modelBuilder.Entity("Wayfarer.Models.AuditLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Action") + .IsRequired() + .HasColumnType("text"); + + b.Property("Details") + .IsRequired() + .HasColumnType("text"); + + b.Property("Timestamp") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("AuditLogs"); + }); + + modelBuilder.Entity("Wayfarer.Models.Group", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("Description") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("GroupType") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("IsArchived") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("OrgPeerVisibilityEnabled") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("OwnerUserId") + .IsRequired() + .HasColumnType("text"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.HasKey("Id"); + + b.HasIndex("OwnerUserId", "Name") + .IsUnique(); + + b.ToTable("Groups"); + }); + + modelBuilder.Entity("Wayfarer.Models.GroupInvitation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("GroupId") + .HasColumnType("uuid"); + + b.Property("InviteeEmail") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("InviteeUserId") + .HasColumnType("text"); + + b.Property("InviterUserId") + .IsRequired() + .HasColumnType("text"); + + b.Property("RespondedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("Token") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("InviteeUserId"); + + b.HasIndex("InviterUserId"); + + b.HasIndex("Token") + .IsUnique(); + + b.HasIndex("GroupId", "InviteeUserId") + .IsUnique() + .HasDatabaseName("IX_GroupInvitation_GroupId_InviteeUserId_Pending") + .HasFilter("\"Status\" = 'Pending' AND \"InviteeUserId\" IS NOT NULL"); + + b.ToTable("GroupInvitations"); + }); + + modelBuilder.Entity("Wayfarer.Models.GroupMember", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("GroupId") + .HasColumnType("uuid"); + + b.Property("JoinedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("LeftAt") + .HasColumnType("timestamp with time zone"); + + b.Property("OrgPeerVisibilityAccessDisabled") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("Role") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.HasIndex("GroupId", "Status") + .HasDatabaseName("IX_GroupMember_GroupId_Status"); + + b.HasIndex("GroupId", "UserId") + .IsUnique(); + + b.ToTable("GroupMembers"); + }); + + modelBuilder.Entity("Wayfarer.Models.HiddenArea", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Area") + .IsRequired() + .HasColumnType("geometry"); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("HiddenAreas"); + }); + + modelBuilder.Entity("Wayfarer.Models.ImageCacheMetadata", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CacheKey") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("ContentType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("FilePath") + .IsRequired() + .HasColumnType("text"); + + b.Property("LastAccessed") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.Property("Size") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("CacheKey") + .IsUnique() + .HasDatabaseName("IX_ImageCacheMetadata_CacheKey"); + + b.ToTable("ImageCacheMetadata"); + }); + + modelBuilder.Entity("Wayfarer.Models.JobHistory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("JobName") + .IsRequired() + .HasColumnType("text"); + + b.Property("LastRunTime") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("JobHistories"); + }); + + modelBuilder.Entity("Wayfarer.Models.Location", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Accuracy") + .HasColumnType("double precision"); + + b.Property("ActivityTypeId") + .HasColumnType("integer"); + + b.Property("Address") + .HasColumnType("text"); + + b.Property("AddressNumber") + .HasColumnType("text"); + + b.Property("Altitude") + .HasColumnType("double precision"); + + b.Property("AppBuild") + .HasColumnType("text"); + + b.Property("AppVersion") + .HasColumnType("text"); + + b.Property("BatteryLevel") + .HasColumnType("integer"); + + b.Property("Bearing") + .HasColumnType("double precision"); + + b.Property("Coordinates") + .IsRequired() + .HasColumnType("geography(Point, 4326)"); + + b.Property("Country") + .HasColumnType("text"); + + b.Property("DeviceModel") + .HasColumnType("text"); + + b.Property("FullAddress") + .HasColumnType("text"); + + b.Property("IdempotencyKey") + .HasColumnType("uuid"); + + b.Property("IsCharging") + .HasColumnType("boolean"); + + b.Property("IsUserInvoked") + .HasColumnType("boolean"); + + b.Property("LocalTimestamp") + .HasColumnType("timestamp with time zone"); + + b.Property("LocationType") + .HasColumnType("text"); + + b.Property("Notes") + .HasColumnType("text"); + + b.Property("OsVersion") + .HasColumnType("text"); + + b.Property("Place") + .HasColumnType("text"); + + b.Property("PostCode") + .HasColumnType("text"); + + b.Property("Provider") + .HasColumnType("text"); + + b.Property("Region") + .HasColumnType("text"); + + b.Property("Source") + .HasColumnType("text"); + + b.Property("Speed") + .HasColumnType("double precision"); + + b.Property("StreetName") + .HasColumnType("text"); + + b.Property("TimeZoneId") + .IsRequired() + .HasColumnType("text"); + + b.Property("Timestamp") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("ActivityTypeId"); + + b.HasIndex("Coordinates") + .HasDatabaseName("IX_Location_Coordinates"); + + NpgsqlIndexBuilderExtensions.HasMethod(b.HasIndex("Coordinates"), "GIST"); + + b.HasIndex("UserId", "IdempotencyKey") + .IsUnique() + .HasDatabaseName("IX_Location_UserId_IdempotencyKey"); + + b.ToTable("Locations"); + }); + + modelBuilder.Entity("Wayfarer.Models.LocationImport", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("ErrorMessage") + .HasColumnType("text"); + + b.Property("FilePath") + .IsRequired() + .HasColumnType("text"); + + b.Property("FileType") + .HasColumnType("integer"); + + b.Property("LastImportedRecord") + .HasColumnType("text"); + + b.Property("LastProcessedIndex") + .HasColumnType("integer"); + + b.Property("SkippedDuplicates") + .HasColumnType("integer"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text"); + + b.Property("TotalRecords") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("LocationImports"); + }); + + modelBuilder.Entity("Wayfarer.Models.LocationProviders.GeoapifyUsageAdmission", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AdmittedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("clock_timestamp()"); + + b.Property("Credits") + .HasColumnType("integer"); + + b.Property("Product") + .HasColumnType("integer"); + + b.Property("UserId") + .IsRequired() + .HasMaxLength(450) + .HasColumnType("character varying(450)"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "AdmittedAt"); + + b.ToTable("GeoapifyUsageAdmissions", t => + { + t.HasCheckConstraint("CK_GeoapifyUsageAdmission_Credits", "\"Credits\" > 0"); + + t.HasCheckConstraint("CK_GeoapifyUsageAdmission_Product", "\"Product\" IN (1, 2)"); + }); + }); + + modelBuilder.Entity("Wayfarer.Models.LocationProviders.GeoapifyUsageGuard", b => + { + b.Property("UserId") + .HasMaxLength(450) + .HasColumnType("character varying(450)"); + + b.Property("CreditLimit") + .HasColumnType("integer"); + + b.Property("Enabled") + .HasColumnType("boolean"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.HasKey("UserId"); + + b.ToTable("GeoapifyUsageGuards", t => + { + t.HasCheckConstraint("CK_GeoapifyUsageGuard_Limit", "\"CreditLimit\" >= 0"); + }); + }); + + modelBuilder.Entity("Wayfarer.Models.LocationProviders.MapboxProductMeter", b => + { + b.Property("UserId") + .HasMaxLength(450) + .HasColumnType("character varying(450)"); + + b.Property("Product") + .HasColumnType("integer"); + + b.Property("AdmittedCount") + .HasColumnType("integer"); + + b.Property("CycleStart") + .HasColumnType("date"); + + b.Property("Enabled") + .HasColumnType("boolean"); + + b.Property("Limit") + .HasColumnType("integer"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.HasKey("UserId", "Product"); + + b.ToTable("MapboxProductMeters", t => + { + t.HasCheckConstraint("CK_MapboxProductMeter_Counts", "\"Limit\" >= 0 AND \"AdmittedCount\" >= 0"); + + t.HasCheckConstraint("CK_MapboxProductMeter_Product", "\"Product\" IN (3, 4)"); + }); + }); + + modelBuilder.Entity("Wayfarer.Models.LocationProviders.PersonalLocationProviderProfile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CredentialGeneration") + .HasColumnType("integer"); + + b.Property("GeocodingAuthorized") + .HasColumnType("boolean"); + + b.Property("GeocodingGeneration") + .HasColumnType("integer"); + + b.Property("GeocodingVerification") + .HasColumnType("integer"); + + b.Property("GeocodingVerifiedConfigurationGeneration") + .HasColumnType("integer"); + + b.Property("GeocodingVerifiedCredentialGeneration") + .HasColumnType("integer"); + + b.Property("LegacyMigrationState") + .HasColumnType("integer"); + + b.Property("ProtectedCredential") + .HasMaxLength(4096) + .HasColumnType("character varying(4096)"); + + b.Property("ProviderKey") + .IsRequired() + .HasMaxLength(24) + .HasColumnType("character varying(24)"); + + b.Property("RevokedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("RoutingAuthorized") + .HasColumnType("boolean"); + + b.Property("RoutingGeneration") + .HasColumnType("integer"); + + b.Property("RoutingVerification") + .HasColumnType("integer"); + + b.Property("RoutingVerifiedConfigurationGeneration") + .HasColumnType("integer"); + + b.Property("RoutingVerifiedCredentialGeneration") + .HasColumnType("integer"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .IsRequired() + .HasMaxLength(450) + .HasColumnType("character varying(450)"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "ProviderKey") + .IsUnique(); + + b.ToTable("PersonalLocationProviderProfiles", t => + { + t.HasCheckConstraint("CK_PersonalProvider_Generations", "\"CredentialGeneration\" > 0 AND \"GeocodingGeneration\" > 0 AND \"RoutingGeneration\" > 0"); + + t.HasCheckConstraint("CK_PersonalProvider_Provider", "\"ProviderKey\" IN ('geoapify', 'mapbox')"); + + t.HasCheckConstraint("CK_PersonalProvider_Verification", "\"GeocodingVerification\" BETWEEN 0 AND 3 AND \"RoutingVerification\" BETWEEN 0 AND 3"); + }); + }); + + modelBuilder.Entity("Wayfarer.Models.LocationProviders.PersonalLocationProviderSelection", b => + { + b.Property("UserId") + .HasMaxLength(450) + .HasColumnType("character varying(450)"); + + b.Property("GeocodingProviderKey") + .HasMaxLength(24) + .HasColumnType("character varying(24)"); + + b.Property("GeocodingSelectionGeneration") + .HasColumnType("integer"); + + b.Property("RoutingProviderKey") + .HasMaxLength(24) + .HasColumnType("character varying(24)"); + + b.Property("RoutingSelectionGeneration") + .HasColumnType("integer"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.HasKey("UserId"); + + b.ToTable("PersonalLocationProviderSelections", t => + { + t.HasCheckConstraint("CK_PersonalProviderSelection_Geocoding", "\"GeocodingProviderKey\" IS NULL OR \"GeocodingProviderKey\" IN ('geoapify', 'mapbox')"); + + t.HasCheckConstraint("CK_PersonalProviderSelection_Routing", "\"RoutingProviderKey\" IS NULL OR \"RoutingProviderKey\" IN ('geoapify', 'mapbox')"); + }); + }); + + modelBuilder.Entity("Wayfarer.Models.Place", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Address") + .HasColumnType("text"); + + b.Property("DisplayOrder") + .HasColumnType("integer"); + + b.Property("IconName") + .HasColumnType("text"); + + b.Property("Location") + .HasColumnType("geography(Point,4326)"); + + b.Property("MarkerColor") + .HasColumnType("text"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("Notes") + .HasColumnType("text"); + + b.Property("RegionId") + .HasColumnType("uuid"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("RegionId"); + + b.ToTable("Places"); + }); + + modelBuilder.Entity("Wayfarer.Models.PlaceVisitCandidate", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConsecutiveHits") + .HasColumnType("integer"); + + b.Property("FirstHitUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("LastHitUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("PlaceId") + .HasColumnType("uuid"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("LastHitUtc") + .HasDatabaseName("IX_PlaceVisitCandidate_LastHitUtc"); + + b.HasIndex("PlaceId"); + + b.HasIndex("UserId", "PlaceId") + .IsUnique() + .HasDatabaseName("IX_PlaceVisitCandidate_UserId_PlaceId"); + + b.ToTable("PlaceVisitCandidates"); + }); + + modelBuilder.Entity("Wayfarer.Models.PlaceVisitEvent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ArrivedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("EndedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("IconNameSnapshot") + .HasColumnType("text"); + + b.Property("LastSeenAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("MarkerColorSnapshot") + .HasColumnType("text"); + + b.Property("NotesHtml") + .HasColumnType("text"); + + b.Property("PlaceId") + .HasColumnType("uuid"); + + b.Property("PlaceLocationSnapshot") + .HasColumnType("geography(Point,4326)"); + + b.Property("PlaceNameSnapshot") + .IsRequired() + .HasColumnType("text"); + + b.Property("RegionNameSnapshot") + .IsRequired() + .HasColumnType("text"); + + b.Property("Source") + .HasColumnType("text"); + + b.Property("TripIdSnapshot") + .HasColumnType("uuid"); + + b.Property("TripNameSnapshot") + .IsRequired() + .HasColumnType("text"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("ArrivedAtUtc") + .HasDatabaseName("IX_PlaceVisitEvent_ArrivedAtUtc"); + + b.HasIndex("PlaceId") + .HasDatabaseName("IX_PlaceVisitEvent_PlaceId"); + + b.HasIndex("UserId", "EndedAtUtc") + .HasDatabaseName("IX_PlaceVisitEvent_UserId_EndedAtUtc"); + + b.ToTable("PlaceVisitEvents"); + }); + + modelBuilder.Entity("Wayfarer.Models.Region", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Center") + .HasColumnType("geography(Point,4326)"); + + b.Property("CoverImageUrl") + .HasColumnType("text"); + + b.Property("DisplayOrder") + .HasColumnType("integer"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("Notes") + .HasColumnType("text"); + + b.Property("TripId") + .HasColumnType("uuid"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("TripId"); + + b.ToTable("Regions"); + }); + + modelBuilder.Entity("Wayfarer.Models.RoutingProviderConfiguration", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AdapterType") + .HasColumnType("integer"); + + b.Property("Attribution") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("BaseEndpoint") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("ConfigurationVersion") + .HasColumnType("integer"); + + b.Property("CredentialCiphertext") + .HasMaxLength(4096) + .HasColumnType("character varying(4096)"); + + b.Property("CredentialPresent") + .HasColumnType("boolean"); + + b.Property("CredentialRequired") + .HasColumnType("boolean"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("Enabled") + .HasColumnType("boolean"); + + b.Property("ExternalCoordinateDisclosure") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("GenerationTimeoutSeconds") + .HasColumnType("integer"); + + b.Property("MaxConcurrency") + .HasColumnType("integer"); + + b.Property("MinimumIntervalMilliseconds") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(1000); + + b.Property("PersonalRoutingAccess") + .HasColumnType("integer"); + + b.Property("RequestsPerMinute") + .HasColumnType("integer"); + + b.Property("ResponseSizeLimitBytes") + .HasColumnType("integer"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.Property("VerificationFromLatitude") + .HasColumnType("double precision"); + + b.Property("VerificationFromLongitude") + .HasColumnType("double precision"); + + b.Property("VerificationResult") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("VerificationStatus") + .HasMaxLength(80) + .HasColumnType("character varying(80)"); + + b.Property("VerificationToLatitude") + .HasColumnType("double precision"); + + b.Property("VerificationToLongitude") + .HasColumnType("double precision"); + + b.Property("VerifiedConfigurationVersion") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.ToTable("RoutingProviderConfigurations", null, t => + { + t.HasCheckConstraint("CK_RoutingProviderConfigurations_MinimumIntervalMilliseconds", "\"MinimumIntervalMilliseconds\" >= 0 AND \"MinimumIntervalMilliseconds\" <= 60000"); + + t.HasCheckConstraint("CK_RoutingProviderConfigurations_PersonalRoutingAccess", "\"PersonalRoutingAccess\" IN (0, 1, 2)"); + }); + }); + + modelBuilder.Entity("Wayfarer.Models.RoutingProviderProfileMapping", b => + { + b.Property("RoutingProviderConfigurationId") + .HasColumnType("uuid"); + + b.Property("TransportProfileId") + .HasColumnType("uuid"); + + b.Property("OsrmProfile") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("character varying(80)"); + + b.HasKey("RoutingProviderConfigurationId", "TransportProfileId"); + + b.HasIndex("TransportProfileId"); + + b.ToTable("RoutingProviderProfileMappings", (string)null); + }); + + modelBuilder.Entity("Wayfarer.Models.Segment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("DisplayOrder") + .HasColumnType("integer"); + + b.Property("EstimatedDistanceKm") + .HasColumnType("double precision"); + + b.Property("EstimatedDuration") + .HasColumnType("interval"); + + b.Property("EstimatedDurationSource") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0); + + b.Property("FromPlaceId") + .HasColumnType("uuid"); + + b.Property("Mode") + .IsRequired() + .HasColumnType("text"); + + b.Property("Notes") + .HasColumnType("text"); + + b.Property("RouteGeometry") + .HasColumnType("geography(LineString,4326)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.Property("ToPlaceId") + .HasColumnType("uuid"); + + b.Property("TransportProfileId") + .HasColumnType("uuid"); + + b.Property("TripId") + .HasColumnType("uuid"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("FromPlaceId"); + + b.HasIndex("ToPlaceId"); + + b.HasIndex("TransportProfileId"); + + b.HasIndex("TripId"); + + b.ToTable("Segments", t => + { + t.HasCheckConstraint("CK_Segments_EstimatedDurationSource", "\"EstimatedDurationSource\" IN (0, 1)"); + }); + }); + + modelBuilder.Entity("Wayfarer.Models.SegmentWaypoint", b => + { + b.Property("SegmentId") + .HasColumnType("uuid"); + + b.Property("PlaceId") + .HasColumnType("uuid"); + + b.Property("Position") + .HasColumnType("integer"); + + b.Property("RouteVertexIndex") + .HasColumnType("integer"); + + b.HasKey("SegmentId", "PlaceId"); + + b.HasIndex("PlaceId"); + + b.HasIndex("SegmentId", "Position") + .IsUnique() + .HasDatabaseName("IX_SegmentWaypoints_SegmentId_Position"); + + b.HasIndex("SegmentId", "RouteVertexIndex") + .IsUnique() + .HasDatabaseName("IX_SegmentWaypoints_SegmentId_RouteVertexIndex") + .HasFilter("\"RouteVertexIndex\" IS NOT NULL"); + + b.ToTable("SegmentWaypoints", null, t => + { + t.HasCheckConstraint("CK_SegmentWaypoint_Position", "\"Position\" >= 0"); + + t.HasCheckConstraint("CK_SegmentWaypoint_RouteVertexIndex", "\"RouteVertexIndex\" IS NULL OR \"RouteVertexIndex\" > 0"); + }); + }); + + modelBuilder.Entity("Wayfarer.Models.Tag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("citext"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.HasIndex("Slug") + .IsUnique(); + + b.ToTable("Tags"); + }); + + modelBuilder.Entity("Wayfarer.Models.TileCacheMetadata", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ETag") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ExpiresAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("LastAccessed") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("LastModifiedUpstream") + .HasColumnType("timestamp with time zone"); + + b.Property("ProviderIdentity") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.Property("Size") + .HasColumnType("integer"); + + b.Property("TileFilePath") + .HasColumnType("text"); + + b.Property("TileLocation") + .IsRequired() + .HasColumnType("geometry"); + + b.Property("X") + .HasColumnType("integer"); + + b.Property("Y") + .HasColumnType("integer"); + + b.Property("Zoom") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("TileLocation"); + + NpgsqlIndexBuilderExtensions.HasMethod(b.HasIndex("TileLocation"), "GIST"); + + b.HasIndex("Zoom", "X", "Y") + .IsUnique() + .HasFilter("\"ProviderIdentity\" IS NULL"); + + b.HasIndex("ProviderIdentity", "Zoom", "X", "Y") + .IsUnique(); + + b.ToTable("TileCacheMetadata"); + }); + + modelBuilder.Entity("Wayfarer.Models.TransportProfile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Category") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("character varying(80)"); + + b.Property("Description") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("IsSeeded") + .HasColumnType("boolean"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("character varying(80)"); + + b.Property("Label") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("PlanningSpeedKmh") + .HasColumnType("double precision"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("Key") + .IsUnique(); + + b.ToTable("TransportProfiles", null, t => + { + t.HasCheckConstraint("CK_TransportProfile_NormalizedKey", "\"Key\" = lower(trim(\"Key\")) AND length(\"Key\") > 0"); + + t.HasCheckConstraint("CK_TransportProfile_PlanningSpeedKmh", "\"PlanningSpeedKmh\" IS NULL OR (\"PlanningSpeedKmh\" > 0 AND \"PlanningSpeedKmh\" < 1.7976931348623157E+308)"); + }); + }); + + modelBuilder.Entity("Wayfarer.Models.Trip", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CenterLat") + .HasColumnType("double precision"); + + b.Property("CenterLon") + .HasColumnType("double precision"); + + b.Property("CoverImageUrl") + .HasColumnType("text"); + + b.Property("IsPublic") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Notes") + .HasColumnType("text"); + + b.Property("ShareProgressEnabled") + .HasColumnType("boolean"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text"); + + b.Property("Zoom") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("Trips"); + }); + + modelBuilder.Entity("Wayfarer.Models.UserRoutingConfiguration", b => + { + b.Property("UserId") + .HasColumnType("text"); + + b.Property("ConfigurationVersion") + .HasColumnType("integer"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("CredentialCiphertext") + .HasMaxLength(4096) + .HasColumnType("character varying(4096)"); + + b.Property("CredentialPresent") + .HasColumnType("boolean"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.Property("SelectedProviderConfigurationId") + .HasColumnType("uuid"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("VerificationStatus") + .HasMaxLength(80) + .HasColumnType("character varying(80)"); + + b.Property("VerifiedProviderConfigurationVersion") + .HasColumnType("integer"); + + b.Property("VerifiedUserConfigurationVersion") + .HasColumnType("integer"); + + b.HasKey("UserId"); + + b.HasIndex("SelectedProviderConfigurationId"); + + b.ToTable("UserRoutingConfigurations", null, t => + { + t.HasCheckConstraint("CK_UserRoutingConfigurations_CredentialConsistency", "(\"CredentialPresent\" AND \"CredentialCiphertext\" IS NOT NULL) OR (NOT \"CredentialPresent\" AND \"CredentialCiphertext\" IS NULL)"); + + t.HasCheckConstraint("CK_UserRoutingConfigurations_DefaultMode", "\"SelectedProviderConfigurationId\" IS NOT NULL OR (NOT \"CredentialPresent\" AND \"CredentialCiphertext\" IS NULL AND \"VerifiedUserConfigurationVersion\" IS NULL AND \"VerifiedProviderConfigurationVersion\" IS NULL AND \"VerificationStatus\" IS NULL)"); + + t.HasCheckConstraint("CK_UserRoutingConfigurations_VerifiedPair", "(\"VerifiedUserConfigurationVersion\" IS NULL AND \"VerifiedProviderConfigurationVersion\" IS NULL) OR (\"VerifiedUserConfigurationVersion\" IS NOT NULL AND \"VerifiedProviderConfigurationVersion\" IS NOT NULL)"); + + t.HasCheckConstraint("CK_UserRoutingConfigurations_Version", "\"ConfigurationVersion\" >= 1"); + }); + }); + + modelBuilder.Entity("ApplicationSettings", b => + { + b.HasOne("Wayfarer.Models.RoutingProviderConfiguration", "ActiveRoutingProviderConfiguration") + .WithMany() + .HasForeignKey("ActiveRoutingProviderConfigurationId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("ActiveRoutingProviderConfiguration"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("Wayfarer.Models.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("Wayfarer.Models.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Wayfarer.Models.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("Wayfarer.Models.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("TripTags", b => + { + b.HasOne("Wayfarer.Models.Tag", null) + .WithMany() + .HasForeignKey("TagId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Wayfarer.Models.Trip", null) + .WithMany() + .HasForeignKey("TripId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Wayfarer.Models.ApiToken", b => + { + b.HasOne("Wayfarer.Models.ApplicationUser", "User") + .WithMany("ApiTokens") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Wayfarer.Models.Area", b => + { + b.HasOne("Wayfarer.Models.Region", "Region") + .WithMany("Areas") + .HasForeignKey("RegionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Region"); + }); + + modelBuilder.Entity("Wayfarer.Models.Group", b => + { + b.HasOne("Wayfarer.Models.ApplicationUser", "Owner") + .WithMany("GroupsOwned") + .HasForeignKey("OwnerUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Owner"); + }); + + modelBuilder.Entity("Wayfarer.Models.GroupInvitation", b => + { + b.HasOne("Wayfarer.Models.Group", "Group") + .WithMany("Invitations") + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Wayfarer.Models.ApplicationUser", "Invitee") + .WithMany("GroupInvitationsReceived") + .HasForeignKey("InviteeUserId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Wayfarer.Models.ApplicationUser", "Inviter") + .WithMany("GroupInvitationsSent") + .HasForeignKey("InviterUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Group"); + + b.Navigation("Invitee"); + + b.Navigation("Inviter"); + }); + + modelBuilder.Entity("Wayfarer.Models.GroupMember", b => + { + b.HasOne("Wayfarer.Models.Group", "Group") + .WithMany("Members") + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Wayfarer.Models.ApplicationUser", "User") + .WithMany("GroupMemberships") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Group"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Wayfarer.Models.HiddenArea", b => + { + b.HasOne("Wayfarer.Models.ApplicationUser", "User") + .WithMany("HiddenAreas") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Wayfarer.Models.Location", b => + { + b.HasOne("Wayfarer.Models.ActivityType", "ActivityType") + .WithMany() + .HasForeignKey("ActivityTypeId"); + + b.HasOne("Wayfarer.Models.ApplicationUser", null) + .WithMany("Locations") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("ActivityType"); + }); + + modelBuilder.Entity("Wayfarer.Models.LocationImport", b => + { + b.HasOne("Wayfarer.Models.ApplicationUser", "User") + .WithMany("LocationImports") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Wayfarer.Models.LocationProviders.GeoapifyUsageAdmission", b => + { + b.HasOne("Wayfarer.Models.LocationProviders.GeoapifyUsageGuard", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Wayfarer.Models.LocationProviders.GeoapifyUsageGuard", b => + { + b.HasOne("Wayfarer.Models.ApplicationUser", null) + .WithOne() + .HasForeignKey("Wayfarer.Models.LocationProviders.GeoapifyUsageGuard", "UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Wayfarer.Models.LocationProviders.MapboxProductMeter", b => + { + b.HasOne("Wayfarer.Models.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Wayfarer.Models.LocationProviders.PersonalLocationProviderProfile", b => + { + b.HasOne("Wayfarer.Models.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Wayfarer.Models.LocationProviders.PersonalLocationProviderSelection", b => + { + b.HasOne("Wayfarer.Models.ApplicationUser", null) + .WithOne() + .HasForeignKey("Wayfarer.Models.LocationProviders.PersonalLocationProviderSelection", "UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Wayfarer.Models.Place", b => + { + b.HasOne("Wayfarer.Models.Region", "Region") + .WithMany("Places") + .HasForeignKey("RegionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Region"); + }); + + modelBuilder.Entity("Wayfarer.Models.PlaceVisitCandidate", b => + { + b.HasOne("Wayfarer.Models.Place", "Place") + .WithMany() + .HasForeignKey("PlaceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Wayfarer.Models.ApplicationUser", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Place"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Wayfarer.Models.PlaceVisitEvent", b => + { + b.HasOne("Wayfarer.Models.Place", "Place") + .WithMany() + .HasForeignKey("PlaceId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Wayfarer.Models.ApplicationUser", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Place"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Wayfarer.Models.Region", b => + { + b.HasOne("Wayfarer.Models.Trip", "Trip") + .WithMany("Regions") + .HasForeignKey("TripId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Trip"); + }); + + modelBuilder.Entity("Wayfarer.Models.RoutingProviderProfileMapping", b => + { + b.HasOne("Wayfarer.Models.RoutingProviderConfiguration", "RoutingProviderConfiguration") + .WithMany("ProfileMappings") + .HasForeignKey("RoutingProviderConfigurationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Wayfarer.Models.TransportProfile", "TransportProfile") + .WithMany() + .HasForeignKey("TransportProfileId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("RoutingProviderConfiguration"); + + b.Navigation("TransportProfile"); + }); + + modelBuilder.Entity("Wayfarer.Models.Segment", b => + { + b.HasOne("Wayfarer.Models.Place", "FromPlace") + .WithMany() + .HasForeignKey("FromPlaceId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Wayfarer.Models.Place", "ToPlace") + .WithMany() + .HasForeignKey("ToPlaceId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Wayfarer.Models.TransportProfile", "TransportProfile") + .WithMany() + .HasForeignKey("TransportProfileId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Wayfarer.Models.Trip", "Trip") + .WithMany("Segments") + .HasForeignKey("TripId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("FromPlace"); + + b.Navigation("ToPlace"); + + b.Navigation("TransportProfile"); + + b.Navigation("Trip"); + }); + + modelBuilder.Entity("Wayfarer.Models.SegmentWaypoint", b => + { + b.HasOne("Wayfarer.Models.Place", "Place") + .WithMany() + .HasForeignKey("PlaceId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Wayfarer.Models.Segment", "Segment") + .WithMany("Waypoints") + .HasForeignKey("SegmentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Place"); + + b.Navigation("Segment"); + }); + + modelBuilder.Entity("Wayfarer.Models.Trip", b => + { + b.HasOne("Wayfarer.Models.ApplicationUser", "User") + .WithMany("Trips") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Wayfarer.Models.UserRoutingConfiguration", b => + { + b.HasOne("Wayfarer.Models.RoutingProviderConfiguration", "SelectedProviderConfiguration") + .WithMany() + .HasForeignKey("SelectedProviderConfigurationId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Wayfarer.Models.ApplicationUser", "User") + .WithOne() + .HasForeignKey("Wayfarer.Models.UserRoutingConfiguration", "UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("SelectedProviderConfiguration"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Wayfarer.Models.ApplicationUser", b => + { + b.Navigation("ApiTokens"); + + b.Navigation("GroupInvitationsReceived"); + + b.Navigation("GroupInvitationsSent"); + + b.Navigation("GroupMemberships"); + + b.Navigation("GroupsOwned"); + + b.Navigation("HiddenAreas"); + + b.Navigation("LocationImports"); + + b.Navigation("Locations"); + + b.Navigation("Trips"); + }); + + modelBuilder.Entity("Wayfarer.Models.Group", b => + { + b.Navigation("Invitations"); + + b.Navigation("Members"); + }); + + modelBuilder.Entity("Wayfarer.Models.Region", b => + { + b.Navigation("Areas"); + + b.Navigation("Places"); + }); + + modelBuilder.Entity("Wayfarer.Models.RoutingProviderConfiguration", b => + { + b.Navigation("ProfileMappings"); + }); + + modelBuilder.Entity("Wayfarer.Models.Segment", b => + { + b.Navigation("Waypoints"); + }); + + modelBuilder.Entity("Wayfarer.Models.Trip", b => + { + b.Navigation("Regions"); + + b.Navigation("Segments"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Migrations/20260823091629_AddPersonalLocationProviderFoundation.cs b/Migrations/20260823091629_AddPersonalLocationProviderFoundation.cs new file mode 100644 index 00000000..069936f6 --- /dev/null +++ b/Migrations/20260823091629_AddPersonalLocationProviderFoundation.cs @@ -0,0 +1,178 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Wayfarer.Migrations +{ + /// + public partial class AddPersonalLocationProviderFoundation : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "GeoapifyUsageGuards", + columns: table => new + { + UserId = table.Column(type: "character varying(450)", maxLength: 450, nullable: false), + Enabled = table.Column(type: "boolean", nullable: false), + CreditLimit = table.Column(type: "integer", nullable: false), + xmin = table.Column(type: "xid", rowVersion: true, nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_GeoapifyUsageGuards", x => x.UserId); + table.CheckConstraint("CK_GeoapifyUsageGuard_Limit", "\"CreditLimit\" >= 0"); + table.ForeignKey( + name: "FK_GeoapifyUsageGuards_AspNetUsers_UserId", + column: x => x.UserId, + principalTable: "AspNetUsers", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "MapboxProductMeters", + columns: table => new + { + UserId = table.Column(type: "character varying(450)", maxLength: 450, nullable: false), + Product = table.Column(type: "integer", nullable: false), + Enabled = table.Column(type: "boolean", nullable: false), + Limit = table.Column(type: "integer", nullable: false), + CycleStart = table.Column(type: "date", nullable: false), + AdmittedCount = table.Column(type: "integer", nullable: false), + xmin = table.Column(type: "xid", rowVersion: true, nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_MapboxProductMeters", x => new { x.UserId, x.Product }); + table.CheckConstraint("CK_MapboxProductMeter_Counts", "\"Limit\" >= 0 AND \"AdmittedCount\" >= 0"); + table.CheckConstraint("CK_MapboxProductMeter_Product", "\"Product\" IN (3, 4)"); + table.ForeignKey( + name: "FK_MapboxProductMeters_AspNetUsers_UserId", + column: x => x.UserId, + principalTable: "AspNetUsers", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "PersonalLocationProviderProfiles", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + UserId = table.Column(type: "character varying(450)", maxLength: 450, nullable: false), + ProviderKey = table.Column(type: "character varying(24)", maxLength: 24, nullable: false), + ProtectedCredential = table.Column(type: "character varying(4096)", maxLength: 4096, nullable: true), + CredentialGeneration = table.Column(type: "integer", nullable: false), + RevokedAt = table.Column(type: "timestamp with time zone", nullable: true), + GeocodingAuthorized = table.Column(type: "boolean", nullable: false), + RoutingAuthorized = table.Column(type: "boolean", nullable: false), + GeocodingGeneration = table.Column(type: "integer", nullable: false), + RoutingGeneration = table.Column(type: "integer", nullable: false), + GeocodingVerification = table.Column(type: "integer", nullable: false), + RoutingVerification = table.Column(type: "integer", nullable: false), + GeocodingVerifiedCredentialGeneration = table.Column(type: "integer", nullable: true), + GeocodingVerifiedConfigurationGeneration = table.Column(type: "integer", nullable: true), + RoutingVerifiedCredentialGeneration = table.Column(type: "integer", nullable: true), + RoutingVerifiedConfigurationGeneration = table.Column(type: "integer", nullable: true), + LegacyMigrationState = table.Column(type: "integer", nullable: false), + UpdatedAt = table.Column(type: "timestamp with time zone", nullable: false), + xmin = table.Column(type: "xid", rowVersion: true, nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_PersonalLocationProviderProfiles", x => x.Id); + table.CheckConstraint("CK_PersonalProvider_Generations", "\"CredentialGeneration\" > 0 AND \"GeocodingGeneration\" > 0 AND \"RoutingGeneration\" > 0"); + table.CheckConstraint("CK_PersonalProvider_Provider", "\"ProviderKey\" IN ('geoapify', 'mapbox')"); + table.CheckConstraint("CK_PersonalProvider_Verification", "\"GeocodingVerification\" BETWEEN 0 AND 3 AND \"RoutingVerification\" BETWEEN 0 AND 3"); + table.ForeignKey( + name: "FK_PersonalLocationProviderProfiles_AspNetUsers_UserId", + column: x => x.UserId, + principalTable: "AspNetUsers", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "PersonalLocationProviderSelections", + columns: table => new + { + UserId = table.Column(type: "character varying(450)", maxLength: 450, nullable: false), + GeocodingProviderKey = table.Column(type: "character varying(24)", maxLength: 24, nullable: true), + RoutingProviderKey = table.Column(type: "character varying(24)", maxLength: 24, nullable: true), + GeocodingSelectionGeneration = table.Column(type: "integer", nullable: false), + RoutingSelectionGeneration = table.Column(type: "integer", nullable: false), + xmin = table.Column(type: "xid", rowVersion: true, nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_PersonalLocationProviderSelections", x => x.UserId); + table.CheckConstraint("CK_PersonalProviderSelection_Geocoding", "\"GeocodingProviderKey\" IS NULL OR \"GeocodingProviderKey\" IN ('geoapify', 'mapbox')"); + table.CheckConstraint("CK_PersonalProviderSelection_Routing", "\"RoutingProviderKey\" IS NULL OR \"RoutingProviderKey\" IN ('geoapify', 'mapbox')"); + table.ForeignKey( + name: "FK_PersonalLocationProviderSelections_AspNetUsers_UserId", + column: x => x.UserId, + principalTable: "AspNetUsers", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "GeoapifyUsageAdmissions", + columns: table => new + { + Id = table.Column(type: "bigint", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + UserId = table.Column(type: "character varying(450)", maxLength: 450, nullable: false), + Credits = table.Column(type: "integer", nullable: false), + Product = table.Column(type: "integer", nullable: false), + AdmittedAt = table.Column(type: "timestamp with time zone", nullable: false, defaultValueSql: "clock_timestamp()") + }, + constraints: table => + { + table.PrimaryKey("PK_GeoapifyUsageAdmissions", x => x.Id); + table.CheckConstraint("CK_GeoapifyUsageAdmission_Credits", "\"Credits\" > 0"); + table.CheckConstraint("CK_GeoapifyUsageAdmission_Product", "\"Product\" IN (1, 2)"); + table.ForeignKey( + name: "FK_GeoapifyUsageAdmissions_GeoapifyUsageGuards_UserId", + column: x => x.UserId, + principalTable: "GeoapifyUsageGuards", + principalColumn: "UserId", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_GeoapifyUsageAdmissions_UserId_AdmittedAt", + table: "GeoapifyUsageAdmissions", + columns: new[] { "UserId", "AdmittedAt" }); + + migrationBuilder.CreateIndex( + name: "IX_PersonalLocationProviderProfiles_UserId_ProviderKey", + table: "PersonalLocationProviderProfiles", + columns: new[] { "UserId", "ProviderKey" }, + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "GeoapifyUsageAdmissions"); + + migrationBuilder.DropTable( + name: "MapboxProductMeters"); + + migrationBuilder.DropTable( + name: "PersonalLocationProviderProfiles"); + + migrationBuilder.DropTable( + name: "PersonalLocationProviderSelections"); + + migrationBuilder.DropTable( + name: "GeoapifyUsageGuards"); + } + } +} diff --git a/Migrations/ApplicationDbContextModelSnapshot.cs b/Migrations/ApplicationDbContextModelSnapshot.cs index 07c41f8c..f2b57207 100644 --- a/Migrations/ApplicationDbContextModelSnapshot.cs +++ b/Migrations/ApplicationDbContextModelSnapshot.cs @@ -18,7 +18,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) { #pragma warning disable 612, 618 modelBuilder - .HasAnnotation("ProductVersion", "10.0.0") + .HasAnnotation("ProductVersion", "10.0.11") .HasAnnotation("Relational:MaxIdentifierLength", 63); NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "citext"); @@ -978,6 +978,224 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("LocationImports"); }); + modelBuilder.Entity("Wayfarer.Models.LocationProviders.GeoapifyUsageAdmission", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AdmittedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("clock_timestamp()"); + + b.Property("Credits") + .HasColumnType("integer"); + + b.Property("Product") + .HasColumnType("integer"); + + b.Property("UserId") + .IsRequired() + .HasMaxLength(450) + .HasColumnType("character varying(450)"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "AdmittedAt"); + + b.ToTable("GeoapifyUsageAdmissions", t => + { + t.HasCheckConstraint("CK_GeoapifyUsageAdmission_Credits", "\"Credits\" > 0"); + + t.HasCheckConstraint("CK_GeoapifyUsageAdmission_Product", "\"Product\" IN (1, 2)"); + }); + }); + + modelBuilder.Entity("Wayfarer.Models.LocationProviders.GeoapifyUsageGuard", b => + { + b.Property("UserId") + .HasMaxLength(450) + .HasColumnType("character varying(450)"); + + b.Property("CreditLimit") + .HasColumnType("integer"); + + b.Property("Enabled") + .HasColumnType("boolean"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.HasKey("UserId"); + + b.ToTable("GeoapifyUsageGuards", t => + { + t.HasCheckConstraint("CK_GeoapifyUsageGuard_Limit", "\"CreditLimit\" >= 0"); + }); + }); + + modelBuilder.Entity("Wayfarer.Models.LocationProviders.MapboxProductMeter", b => + { + b.Property("UserId") + .HasMaxLength(450) + .HasColumnType("character varying(450)"); + + b.Property("Product") + .HasColumnType("integer"); + + b.Property("AdmittedCount") + .HasColumnType("integer"); + + b.Property("CycleStart") + .HasColumnType("date"); + + b.Property("Enabled") + .HasColumnType("boolean"); + + b.Property("Limit") + .HasColumnType("integer"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.HasKey("UserId", "Product"); + + b.ToTable("MapboxProductMeters", t => + { + t.HasCheckConstraint("CK_MapboxProductMeter_Counts", "\"Limit\" >= 0 AND \"AdmittedCount\" >= 0"); + + t.HasCheckConstraint("CK_MapboxProductMeter_Product", "\"Product\" IN (3, 4)"); + }); + }); + + modelBuilder.Entity("Wayfarer.Models.LocationProviders.PersonalLocationProviderProfile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CredentialGeneration") + .HasColumnType("integer"); + + b.Property("GeocodingAuthorized") + .HasColumnType("boolean"); + + b.Property("GeocodingGeneration") + .HasColumnType("integer"); + + b.Property("GeocodingVerification") + .HasColumnType("integer"); + + b.Property("GeocodingVerifiedConfigurationGeneration") + .HasColumnType("integer"); + + b.Property("GeocodingVerifiedCredentialGeneration") + .HasColumnType("integer"); + + b.Property("LegacyMigrationState") + .HasColumnType("integer"); + + b.Property("ProtectedCredential") + .HasMaxLength(4096) + .HasColumnType("character varying(4096)"); + + b.Property("ProviderKey") + .IsRequired() + .HasMaxLength(24) + .HasColumnType("character varying(24)"); + + b.Property("RevokedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("RoutingAuthorized") + .HasColumnType("boolean"); + + b.Property("RoutingGeneration") + .HasColumnType("integer"); + + b.Property("RoutingVerification") + .HasColumnType("integer"); + + b.Property("RoutingVerifiedConfigurationGeneration") + .HasColumnType("integer"); + + b.Property("RoutingVerifiedCredentialGeneration") + .HasColumnType("integer"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .IsRequired() + .HasMaxLength(450) + .HasColumnType("character varying(450)"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "ProviderKey") + .IsUnique(); + + b.ToTable("PersonalLocationProviderProfiles", t => + { + t.HasCheckConstraint("CK_PersonalProvider_Generations", "\"CredentialGeneration\" > 0 AND \"GeocodingGeneration\" > 0 AND \"RoutingGeneration\" > 0"); + + t.HasCheckConstraint("CK_PersonalProvider_Provider", "\"ProviderKey\" IN ('geoapify', 'mapbox')"); + + t.HasCheckConstraint("CK_PersonalProvider_Verification", "\"GeocodingVerification\" BETWEEN 0 AND 3 AND \"RoutingVerification\" BETWEEN 0 AND 3"); + }); + }); + + modelBuilder.Entity("Wayfarer.Models.LocationProviders.PersonalLocationProviderSelection", b => + { + b.Property("UserId") + .HasMaxLength(450) + .HasColumnType("character varying(450)"); + + b.Property("GeocodingProviderKey") + .HasMaxLength(24) + .HasColumnType("character varying(24)"); + + b.Property("GeocodingSelectionGeneration") + .HasColumnType("integer"); + + b.Property("RoutingProviderKey") + .HasMaxLength(24) + .HasColumnType("character varying(24)"); + + b.Property("RoutingSelectionGeneration") + .HasColumnType("integer"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.HasKey("UserId"); + + b.ToTable("PersonalLocationProviderSelections", t => + { + t.HasCheckConstraint("CK_PersonalProviderSelection_Geocoding", "\"GeocodingProviderKey\" IS NULL OR \"GeocodingProviderKey\" IN ('geoapify', 'mapbox')"); + + t.HasCheckConstraint("CK_PersonalProviderSelection_Routing", "\"RoutingProviderKey\" IS NULL OR \"RoutingProviderKey\" IN ('geoapify', 'mapbox')"); + }); + }); + modelBuilder.Entity("Wayfarer.Models.Place", b => { b.Property("Id") @@ -1832,6 +2050,51 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Navigation("User"); }); + modelBuilder.Entity("Wayfarer.Models.LocationProviders.GeoapifyUsageAdmission", b => + { + b.HasOne("Wayfarer.Models.LocationProviders.GeoapifyUsageGuard", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Wayfarer.Models.LocationProviders.GeoapifyUsageGuard", b => + { + b.HasOne("Wayfarer.Models.ApplicationUser", null) + .WithOne() + .HasForeignKey("Wayfarer.Models.LocationProviders.GeoapifyUsageGuard", "UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Wayfarer.Models.LocationProviders.MapboxProductMeter", b => + { + b.HasOne("Wayfarer.Models.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Wayfarer.Models.LocationProviders.PersonalLocationProviderProfile", b => + { + b.HasOne("Wayfarer.Models.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Wayfarer.Models.LocationProviders.PersonalLocationProviderSelection", b => + { + b.HasOne("Wayfarer.Models.ApplicationUser", null) + .WithOne() + .HasForeignKey("Wayfarer.Models.LocationProviders.PersonalLocationProviderSelection", "UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + modelBuilder.Entity("Wayfarer.Models.Place", b => { b.HasOne("Wayfarer.Models.Region", "Region") diff --git a/Models/ApplicationDbContext.cs b/Models/ApplicationDbContext.cs index 7c908376..eccf96f9 100644 --- a/Models/ApplicationDbContext.cs +++ b/Models/ApplicationDbContext.cs @@ -1,6 +1,7 @@ using Microsoft.AspNetCore.Identity.EntityFrameworkCore; using Microsoft.EntityFrameworkCore; using Npgsql.EntityFrameworkCore.PostgreSQL; +using Wayfarer.Models.LocationProviders; namespace Wayfarer.Models { @@ -16,7 +17,17 @@ public ApplicationDbContext(DbContextOptions options, } public DbSet Locations { get; set; } - public DbSet ApiTokens { get; set; } + public DbSet ApiTokens { get; set; } + /// Gets personal provider profiles. + public DbSet PersonalLocationProviderProfiles { get; set; } + /// Gets independent active provider selections. + public DbSet PersonalLocationProviderSelections { get; set; } + /// Gets Geoapify shared-pool guard rows. + public DbSet GeoapifyUsageGuards { get; set; } + /// Gets rolling Geoapify admissions. + public DbSet GeoapifyUsageAdmissions { get; set; } + /// Gets independent Mapbox product meters. + public DbSet MapboxProductMeters { get; set; } public DbSet ApplicationUsers { get; set; } public DbSet AuditLogs { get; set; } diff --git a/Models/Configuration/PersonalLocationProviderConfiguration.cs b/Models/Configuration/PersonalLocationProviderConfiguration.cs new file mode 100644 index 00000000..cf502354 --- /dev/null +++ b/Models/Configuration/PersonalLocationProviderConfiguration.cs @@ -0,0 +1,39 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using Wayfarer.Models.LocationProviders; + +namespace Wayfarer.Models.Configuration; + +/// Defines bounded relational authority for personal provider profiles and selections. +public sealed class PersonalLocationProviderConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.HasKey(item => item.Id); + builder.HasIndex(item => new { item.UserId, item.ProviderKey }).IsUnique(); + builder.HasOne().WithMany().HasForeignKey(item => item.UserId).OnDelete(DeleteBehavior.Cascade); + builder.Property(item => item.RowVersion).HasColumnName("xmin").IsRowVersion().ValueGeneratedOnAddOrUpdate(); + builder.ToTable(table => + { + table.HasCheckConstraint("CK_PersonalProvider_Provider", "\"ProviderKey\" IN ('geoapify', 'mapbox')"); + table.HasCheckConstraint("CK_PersonalProvider_Generations", "\"CredentialGeneration\" > 0 AND \"GeocodingGeneration\" > 0 AND \"RoutingGeneration\" > 0"); + table.HasCheckConstraint("CK_PersonalProvider_Verification", "\"GeocodingVerification\" BETWEEN 0 AND 3 AND \"RoutingVerification\" BETWEEN 0 AND 3"); + }); + } +} + +/// Defines independent provider selection integrity. +public sealed class PersonalLocationProviderSelectionConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.HasKey(item => item.UserId); + builder.HasOne().WithOne().HasForeignKey(item => item.UserId).OnDelete(DeleteBehavior.Cascade); + builder.Property(item => item.RowVersion).HasColumnName("xmin").IsRowVersion().ValueGeneratedOnAddOrUpdate(); + builder.ToTable(table => + { + table.HasCheckConstraint("CK_PersonalProviderSelection_Geocoding", "\"GeocodingProviderKey\" IS NULL OR \"GeocodingProviderKey\" IN ('geoapify', 'mapbox')"); + table.HasCheckConstraint("CK_PersonalProviderSelection_Routing", "\"RoutingProviderKey\" IS NULL OR \"RoutingProviderKey\" IN ('geoapify', 'mapbox')"); + }); + } +} diff --git a/Models/Configuration/PersonalProviderUsageConfiguration.cs b/Models/Configuration/PersonalProviderUsageConfiguration.cs new file mode 100644 index 00000000..9590c435 --- /dev/null +++ b/Models/Configuration/PersonalProviderUsageConfiguration.cs @@ -0,0 +1,50 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using Wayfarer.Models.LocationProviders; + +namespace Wayfarer.Models.Configuration; + +/// Defines the stable per-user Geoapify guard lock row. +public sealed class GeoapifyUsageGuardConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.HasKey(item => item.UserId); + builder.HasOne().WithOne().HasForeignKey(item => item.UserId).OnDelete(DeleteBehavior.Cascade); + builder.Property(item => item.RowVersion).HasColumnName("xmin").IsRowVersion().ValueGeneratedOnAddOrUpdate(); + builder.ToTable(table => table.HasCheckConstraint("CK_GeoapifyUsageGuard_Limit", "\"CreditLimit\" >= 0")); + } +} + +/// Defines exact rolling-window admissions and their authority index. +public sealed class GeoapifyUsageAdmissionConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.HasKey(item => item.Id); + builder.Property(item => item.AdmittedAt).HasDefaultValueSql("clock_timestamp()").ValueGeneratedOnAdd(); + builder.HasIndex(item => new { item.UserId, item.AdmittedAt }); + builder.HasOne().WithMany().HasForeignKey(item => item.UserId).OnDelete(DeleteBehavior.Cascade); + builder.ToTable(table => + { + table.HasCheckConstraint("CK_GeoapifyUsageAdmission_Credits", "\"Credits\" > 0"); + table.HasCheckConstraint("CK_GeoapifyUsageAdmission_Product", "\"Product\" IN (1, 2)"); + }); + } +} + +/// Defines independent durable Mapbox product meters. +public sealed class MapboxProductMeterConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.HasKey(item => new { item.UserId, item.Product }); + builder.HasOne().WithMany().HasForeignKey(item => item.UserId).OnDelete(DeleteBehavior.Cascade); + builder.Property(item => item.RowVersion).HasColumnName("xmin").IsRowVersion().ValueGeneratedOnAddOrUpdate(); + builder.ToTable(table => + { + table.HasCheckConstraint("CK_MapboxProductMeter_Product", "\"Product\" IN (3, 4)"); + table.HasCheckConstraint("CK_MapboxProductMeter_Counts", "\"Limit\" >= 0 AND \"AdmittedCount\" >= 0"); + }); + } +} diff --git a/Models/LocationProviders/LegacyMapboxMigration.cs b/Models/LocationProviders/LegacyMapboxMigration.cs new file mode 100644 index 00000000..0351df6a --- /dev/null +++ b/Models/LocationProviders/LegacyMapboxMigration.cs @@ -0,0 +1,29 @@ +namespace Wayfarer.Models.LocationProviders; + +/// Identifies bounded non-secret legacy migration outcomes. +public enum LegacyMapboxMigrationState +{ None = 0, Migrated = 1, Conflict = 2, ProtectedCredentialUnavailable = 3, Revoked = 4 } + +/// Represents a bounded credential read result. +public sealed record PersonalCredentialRead(bool Succeeded, string? Credential) +{ + public static PersonalCredentialRead Unavailable { get; } = new(false, null); +} + +/// Represents the non-destructive retirement decision. +public sealed record LegacyMapboxMigrationDecision(bool RetireLegacy, LegacyMapboxMigrationState State); + +/// Centralizes fail-closed legacy retirement decisions. +public static class LegacyMapboxMigration +{ + /// Never retires plaintext unless protected readback is available and unambiguous. + public static LegacyMapboxMigrationDecision Decide(PersonalCredentialRead protectedRead, IReadOnlyCollection recognizedLegacyValues) + { + if (recognizedLegacyValues.Distinct(StringComparer.Ordinal).Count() > 1) + return new(false, LegacyMapboxMigrationState.Conflict); + if (!protectedRead.Succeeded) + return new(false, LegacyMapboxMigrationState.ProtectedCredentialUnavailable); + return new(recognizedLegacyValues.Count == 1 && recognizedLegacyValues.Single() == protectedRead.Credential, + LegacyMapboxMigrationState.Migrated); + } +} diff --git a/Models/LocationProviders/PersonalLocationProviderProfile.cs b/Models/LocationProviders/PersonalLocationProviderProfile.cs new file mode 100644 index 00000000..cde4d725 --- /dev/null +++ b/Models/LocationProviders/PersonalLocationProviderProfile.cs @@ -0,0 +1,100 @@ +using System.ComponentModel.DataAnnotations; + +namespace Wayfarer.Models.LocationProviders; + +/// Identifies a supported personal location provider. +public enum PersonalLocationProvider { Geoapify = 1, Mapbox = 2 } + +/// Identifies an independently authorized provider capability. +public enum PersonalProviderCapability { Geocoding = 1, Routing = 2 } + +/// Identifies bounded provider-native products used by usage diagnostics. +public enum PersonalProviderProduct { Geocoding = 1, Routing = 2, PermanentGeocoding = 3, Directions = 4 } + +/// Contains bounded verification state and never provider response content. +public enum PersonalProviderVerification { Unverified = 0, Verified = 1, Failed = 2, Unavailable = 3 } + +/// Owns one protected credential and independent capability authority for one user/provider. +public sealed class PersonalLocationProviderProfile +{ + /// Gets or sets the stable profile identifier. + public Guid Id { get; set; } = Guid.NewGuid(); + /// Gets or sets the owning Identity user. + [StringLength(450)] public string UserId { get; set; } = string.Empty; + /// Gets or sets the normalized stable provider key. + [StringLength(24)] public string ProviderKey { get; set; } = string.Empty; + /// Gets or sets protected credential material. + [StringLength(4096)] public string? ProtectedCredential { get; set; } + /// Gets or sets the monotonic credential authority generation. + public int CredentialGeneration { get; set; } = 1; + /// Gets or sets when the credential was explicitly revoked. + public DateTimeOffset? RevokedAt { get; set; } + /// Gets or sets independent geocoding authorization. + public bool GeocodingAuthorized { get; set; } + /// Gets or sets independent routing authorization. + public bool RoutingAuthorized { get; set; } + /// Gets or sets the geocoding configuration generation. + public int GeocodingGeneration { get; set; } = 1; + /// Gets or sets the routing configuration generation. + public int RoutingGeneration { get; set; } = 1; + /// Gets or sets bounded geocoding verification. + public PersonalProviderVerification GeocodingVerification { get; set; } + /// Gets or sets bounded routing verification. + public PersonalProviderVerification RoutingVerification { get; set; } + /// Gets or sets the credential generation verified for geocoding. + public int? GeocodingVerifiedCredentialGeneration { get; set; } + /// Gets or sets the capability generation verified for geocoding. + public int? GeocodingVerifiedConfigurationGeneration { get; set; } + /// Gets or sets the credential generation verified for routing. + public int? RoutingVerifiedCredentialGeneration { get; set; } + /// Gets or sets the capability generation verified for routing. + public int? RoutingVerifiedConfigurationGeneration { get; set; } + /// Gets or sets bounded legacy migration state. + public LegacyMapboxMigrationState LegacyMigrationState { get; set; } + /// Gets or sets the last mutation time. + public DateTimeOffset UpdatedAt { get; set; } = DateTimeOffset.UtcNow; + /// Gets the PostgreSQL concurrency token. + public uint RowVersion { get; private set; } + + /// Creates an empty normalized profile. + public static PersonalLocationProviderProfile Create(string userId, PersonalLocationProvider provider) => new() + { UserId = userId, ProviderKey = PersonalProviderKeys.Key(provider) }; + + /// Returns whether the capability is explicitly authorized. + public bool IsAuthorized(PersonalProviderCapability capability) => capability switch + { + PersonalProviderCapability.Geocoding => GeocodingAuthorized, + PersonalProviderCapability.Routing => RoutingAuthorized, + _ => false + }; + + /// Changes only the requested capability authority and invalidates its verification. + public void SetAuthorization(PersonalProviderCapability capability, bool authorized) + { + if (capability == PersonalProviderCapability.Geocoding && GeocodingAuthorized != authorized) + { GeocodingAuthorized = authorized; GeocodingGeneration++; ClearVerification(capability); } + else if (capability == PersonalProviderCapability.Routing && RoutingAuthorized != authorized) + { RoutingAuthorized = authorized; RoutingGeneration++; ClearVerification(capability); } + UpdatedAt = DateTimeOffset.UtcNow; + } + + /// Clears bounded verification for one capability. + public void ClearVerification(PersonalProviderCapability capability) + { + if (capability == PersonalProviderCapability.Geocoding) + { GeocodingVerification = PersonalProviderVerification.Unverified; GeocodingVerifiedCredentialGeneration = null; GeocodingVerifiedConfigurationGeneration = null; } + else + { RoutingVerification = PersonalProviderVerification.Unverified; RoutingVerifiedCredentialGeneration = null; RoutingVerifiedConfigurationGeneration = null; } + } +} + +/// Normalizes the only supported provider identities. +public static class PersonalProviderKeys +{ + /// Gets a stable lower-case storage key. + public static string Key(PersonalLocationProvider provider) => provider switch + { PersonalLocationProvider.Geoapify => "geoapify", PersonalLocationProvider.Mapbox => "mapbox", _ => throw new ArgumentOutOfRangeException(nameof(provider)) }; + + /// Recognizes only the exact trimmed legacy Mapbox identity. + public static bool IsLegacyMapbox(string? value) => string.Equals(value?.Trim(), "Mapbox", StringComparison.OrdinalIgnoreCase); +} diff --git a/Models/LocationProviders/PersonalLocationProviderSelection.cs b/Models/LocationProviders/PersonalLocationProviderSelection.cs new file mode 100644 index 00000000..433515fb --- /dev/null +++ b/Models/LocationProviders/PersonalLocationProviderSelection.cs @@ -0,0 +1,33 @@ +using System.ComponentModel.DataAnnotations; + +namespace Wayfarer.Models.LocationProviders; + +/// Stores independent nullable active provider selections without owning credentials. +public sealed class PersonalLocationProviderSelection +{ + /// Gets or sets the owning user and primary key. + [StringLength(450)] public string UserId { get; set; } = string.Empty; + /// Gets or sets the active geocoding provider key; null means no provider. + [StringLength(24)] public string? GeocodingProviderKey { get; set; } + /// Gets or sets the active routing provider key; null means no provider. + [StringLength(24)] public string? RoutingProviderKey { get; set; } + /// Gets or sets the geocoding selection generation. + public int GeocodingSelectionGeneration { get; set; } = 1; + /// Gets or sets the routing selection generation. + public int RoutingSelectionGeneration { get; set; } = 1; + /// Gets the PostgreSQL concurrency token. + public uint RowVersion { get; private set; } + + /// Creates the no-provider state. + public static PersonalLocationProviderSelection Create(string userId) => new() { UserId = userId }; + + /// Changes one selection only and advances its stale-work generation. + public void Select(PersonalProviderCapability capability, PersonalLocationProvider? provider) + { + var key = provider is null ? null : PersonalProviderKeys.Key(provider.Value); + if (capability == PersonalProviderCapability.Geocoding && GeocodingProviderKey != key) + { GeocodingProviderKey = key; GeocodingSelectionGeneration++; } + else if (capability == PersonalProviderCapability.Routing && RoutingProviderKey != key) + { RoutingProviderKey = key; RoutingSelectionGeneration++; } + } +} diff --git a/Models/LocationProviders/PersonalProviderUsage.cs b/Models/LocationProviders/PersonalProviderUsage.cs new file mode 100644 index 00000000..b6b672ec --- /dev/null +++ b/Models/LocationProviders/PersonalProviderUsage.cs @@ -0,0 +1,34 @@ +using System.ComponentModel.DataAnnotations; + +namespace Wayfarer.Models.LocationProviders; + +/// Stores the stable Geoapify shared rolling-credit guard. +public sealed class GeoapifyUsageGuard +{ + [StringLength(450)] public string UserId { get; set; } = string.Empty; + public bool Enabled { get; set; } = true; + public int CreditLimit { get; set; } = 2500; + public uint RowVersion { get; private set; } +} + +/// Records one admitted Geoapify contact without personal request content. +public sealed class GeoapifyUsageAdmission +{ + public long Id { get; set; } + [StringLength(450)] public string UserId { get; set; } = string.Empty; + public int Credits { get; set; } + public PersonalProviderProduct Product { get; set; } + public DateTimeOffset AdmittedAt { get; set; } +} + +/// Stores one durable Mapbox product safety-cycle counter. +public sealed class MapboxProductMeter +{ + [StringLength(450)] public string UserId { get; set; } = string.Empty; + public PersonalProviderProduct Product { get; set; } + public bool Enabled { get; set; } = true; + public int Limit { get; set; } = 1000; + public DateOnly CycleStart { get; set; } + public int AdmittedCount { get; set; } + public uint RowVersion { get; private set; } +} diff --git a/Program.cs b/Program.cs index 465a2583..ca24c25b 100644 --- a/Program.cs +++ b/Program.cs @@ -20,6 +20,7 @@ using Wayfarer.Parsers; using Wayfarer.Services; using Wayfarer.Services.ExternalRouting; +using Wayfarer.Services.LocationProviders; using Wayfarer.Swagger; using Wayfarer.Util; using IPNetwork = System.Net.IPNetwork; @@ -140,6 +141,7 @@ static void ConfigureForwardedHeaders(WebApplicationBuilder builder) // Seed the database with roles and the admin user if necessary await SeedDatabase(app); +await DataProtectionAuthority.ValidateAsync(app.Services); #endregion Database Seeding @@ -470,6 +472,8 @@ static void ConfigureQuartz(WebApplicationBuilder builder) // Method to configure services for the application static void ConfigureServices(WebApplicationBuilder builder) { + // Use one explicit durable authority for Identity and all protected provider credentials. + builder.AddWayfarerDataProtection(); // Explicitly register IHttpContextAccessor for services that need it (e.g., TileCacheService). // Some framework components may register it implicitly, but explicit registration is safer. builder.Services.AddHttpContextAccessor(); @@ -484,6 +488,7 @@ static void ConfigureServices(WebApplicationBuilder builder) // Register ApiTokenService with DI container builder.Services.AddScoped(); + builder.Services.AddScoped(); // IRegistrationService as a transient or singleton service builder.Services.AddTransient(); diff --git a/Services/LocationProviders/DataProtectionAuthority.cs b/Services/LocationProviders/DataProtectionAuthority.cs new file mode 100644 index 00000000..f6d6ffd6 --- /dev/null +++ b/Services/LocationProviders/DataProtectionAuthority.cs @@ -0,0 +1,70 @@ +using Microsoft.AspNetCore.DataProtection; +using Microsoft.EntityFrameworkCore; +using System.Security.Cryptography; +using Wayfarer.Models; +using Wayfarer.Models.LocationProviders; +using Wayfarer.Services.ExternalRouting; + +namespace Wayfarer.Services.LocationProviders; + +/// Configures and validates the persistent single-host Data Protection authority. +public static class DataProtectionAuthority +{ + /// Registers one explicit persistent key ring shared by every application protector. + public static void AddWayfarerDataProtection(this WebApplicationBuilder builder) + { + var configured = builder.Configuration["DataProtection:KeyRingPath"]; + var path = string.IsNullOrWhiteSpace(configured) + ? Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), + "Wayfarer", "DataProtectionKeys") + : Path.GetFullPath(configured); + Directory.CreateDirectory(path); + builder.Services.AddDataProtection() + .SetApplicationName("Wayfarer") + .PersistKeysToFileSystem(new DirectoryInfo(path)); + builder.Services.AddSingleton(new DataProtectionKeyRing(path)); + } + + /// Fails startup when the key ring cannot round-trip or retained protected credentials cannot be read. + public static async Task ValidateAsync(IServiceProvider services, CancellationToken cancellationToken = default) + { + using var scope = services.CreateScope(); + var provider = scope.ServiceProvider.GetRequiredService(); + var keyRing = scope.ServiceProvider.GetRequiredService(); + var probe = provider.CreateProtector("Wayfarer.DataProtection.StartupProbe.v1"); + try + { + var probeFile = Path.Combine(keyRing.Path, $".write-probe-{Guid.NewGuid():N}"); + await File.WriteAllTextAsync(probeFile, "probe", cancellationToken); + File.Delete(probeFile); + if (probe.Unprotect(probe.Protect("ready")) != "ready") throw new InvalidOperationException(); + } + catch (Exception exception) when (exception is IOException or UnauthorizedAccessException or CryptographicException or InvalidOperationException) + { + throw new InvalidOperationException("The configured Data Protection key authority is unusable.", exception); + } + + var db = scope.ServiceProvider.GetRequiredService(); + var personal = scope.ServiceProvider.GetRequiredService(); + foreach (var profile in await db.Set().AsNoTracking() + .Where(item => item.ProtectedCredential != null && item.RevokedAt == null).ToListAsync(cancellationToken)) + if (!personal.Read(profile).Succeeded) + throw new InvalidOperationException("A protected personal provider credential is unreadable with the configured key authority."); + + var adminOwner = scope.ServiceProvider.GetRequiredService(); + foreach (var configuration in await db.Set().AsNoTracking() + .Where(item => item.CredentialCiphertext != null).ToListAsync(cancellationToken)) + if (!adminOwner.Read(configuration).Succeeded) + throw new InvalidOperationException("A protected administrator routing credential is unreadable with the configured key authority."); + + var userOwner = scope.ServiceProvider.GetRequiredService(); + foreach (var configuration in await db.Set().AsNoTracking() + .Where(item => item.CredentialCiphertext != null && item.SelectedProviderConfigurationId != null).ToListAsync(cancellationToken)) + if (!userOwner.Unprotect(configuration.UserId, configuration.SelectedProviderConfigurationId!.Value, + configuration.CredentialCiphertext).Succeeded) + throw new InvalidOperationException("A protected personal routing credential is unreadable with the configured key authority."); + } +} + +/// Describes the configured durable key-ring path without exposing key material. +public sealed record DataProtectionKeyRing(string Path); diff --git a/Services/LocationProviders/PersonalProviderCredentialService.cs b/Services/LocationProviders/PersonalProviderCredentialService.cs new file mode 100644 index 00000000..ba2ae874 --- /dev/null +++ b/Services/LocationProviders/PersonalProviderCredentialService.cs @@ -0,0 +1,54 @@ +using System.Security.Cryptography; +using Microsoft.AspNetCore.DataProtection; +using Wayfarer.Models.LocationProviders; + +namespace Wayfarer.Services.LocationProviders; + +/// Exclusively protects and reads personal provider credentials bound to provider and user. +public sealed class PersonalProviderCredentialService +{ + /// Gets the immutable root protection purpose. + public const string ProtectionPurpose = "Wayfarer.LocationProviders.PersonalCredentials.v1"; + private readonly IDataProtectionProvider _provider; + + /// Creates the credential owner. + public PersonalProviderCredentialService(IDataProtectionProvider provider) => _provider = provider; + + /// Protects a nonblank replacement, advances generation, and preserves authorizations. + public void Replace(PersonalLocationProviderProfile profile, string credential) + { + ArgumentException.ThrowIfNullOrWhiteSpace(credential); + profile.ProtectedCredential = Protector(profile).Protect(credential.Trim()); + profile.CredentialGeneration = checked(profile.CredentialGeneration + 1); + profile.RevokedAt = null; + profile.ClearVerification(PersonalProviderCapability.Geocoding); + profile.ClearVerification(PersonalProviderCapability.Routing); + profile.UpdatedAt = DateTimeOffset.UtcNow; + } + + /// Reads a credential as a bounded unavailable result without mutating ciphertext. + public PersonalCredentialRead Read(PersonalLocationProviderProfile profile) + { + if (profile.RevokedAt != null || string.IsNullOrEmpty(profile.ProtectedCredential)) + return PersonalCredentialRead.Unavailable; + try { return new(true, Protector(profile).Unprotect(profile.ProtectedCredential)); } + catch (CryptographicException) { return PersonalCredentialRead.Unavailable; } + } + + /// Explicitly revokes contact authority while preserving profile and usage history. + public void Revoke(PersonalLocationProviderProfile profile) + { + profile.ProtectedCredential = null; + profile.CredentialGeneration = checked(profile.CredentialGeneration + 1); + profile.RevokedAt = DateTimeOffset.UtcNow; + profile.SetAuthorization(PersonalProviderCapability.Geocoding, false); + profile.SetAuthorization(PersonalProviderCapability.Routing, false); + profile.ClearVerification(PersonalProviderCapability.Geocoding); + profile.ClearVerification(PersonalProviderCapability.Routing); + profile.UpdatedAt = DateTimeOffset.UtcNow; + } + + private IDataProtector Protector(PersonalLocationProviderProfile profile) => _provider + .CreateProtector(ProtectionPurpose).CreateProtector("credential") + .CreateProtector(profile.ProviderKey).CreateProtector(profile.UserId); +} diff --git a/Services/LocationProviders/PersonalProviderUsageLedger.cs b/Services/LocationProviders/PersonalProviderUsageLedger.cs new file mode 100644 index 00000000..4a800d8b --- /dev/null +++ b/Services/LocationProviders/PersonalProviderUsageLedger.cs @@ -0,0 +1,39 @@ +using Wayfarer.Models.LocationProviders; + +namespace Wayfarer.Services.LocationProviders; + +/// Provides deterministic in-memory semantics used by callers and unit tests; PostgreSQL owns durable admission. +public sealed class PersonalProviderUsageLedger +{ + private readonly List<(DateTimeOffset At, int Credits)> _geoapify = []; + private readonly Dictionary<(DateOnly Cycle, PersonalProviderProduct Product), int> _mapbox = []; + + /// Atomically admits positive credits against one shared rolling pool. + public bool TryAdmitGeoapify(DateTimeOffset now, int limit, int credits, PersonalProviderProduct product) + { + if (credits <= 0 || limit < 0 || product is not (PersonalProviderProduct.Geocoding or PersonalProviderProduct.Routing)) + return false; + lock (_geoapify) + { + _geoapify.RemoveAll(item => item.At <= now.AddHours(-24)); + if (_geoapify.Sum(item => item.Credits) + credits > limit) return false; + _geoapify.Add((now, credits)); + return true; + } + } + + /// Atomically admits one Mapbox contact against its independent product cycle. + public bool TryAdmitMapbox(DateOnly cycle, PersonalProviderProduct product, int limit, int cost) + { + if (cost <= 0 || limit < 0 || product is not (PersonalProviderProduct.PermanentGeocoding or PersonalProviderProduct.Directions)) + return false; + lock (_mapbox) + { + var key = (cycle, product); + var used = _mapbox.GetValueOrDefault(key); + if (used + cost > limit) return false; + _mapbox[key] = used + cost; + return true; + } + } +} diff --git a/tests/Wayfarer.Tests/Services/PersonalLocationProviderFoundationTests.cs b/tests/Wayfarer.Tests/Services/PersonalLocationProviderFoundationTests.cs index 6c6dfc92..a93f7a56 100644 --- a/tests/Wayfarer.Tests/Services/PersonalLocationProviderFoundationTests.cs +++ b/tests/Wayfarer.Tests/Services/PersonalLocationProviderFoundationTests.cs @@ -62,7 +62,7 @@ public void GeoapifyAdmission_UsesOneRollingSharedCreditPool() var ledger = new PersonalProviderUsageLedger(); var now = new DateTimeOffset(2026, 8, 23, 12, 0, 0, TimeSpan.Zero); - Assert.True(ledger.TryAdmitGeoapify(now, 2_500, 2_499, PersonalProviderProduct.Geocoding)); + Assert.True(ledger.TryAdmitGeoapify(now, 2_500, 2_500, PersonalProviderProduct.Geocoding)); Assert.False(ledger.TryAdmitGeoapify(now, 2_500, 1, PersonalProviderProduct.Routing)); } From 2c165b1ac0b5c498eaf98789fe2e4b05dcf29c25 Mon Sep 17 00:00:00 2001 From: Stef Kariotidis Date: Sun, 23 Aug 2026 12:20:14 +0300 Subject: [PATCH 03/13] feat(providers): migrate legacy Mapbox credentials safely --- Program.cs | 2 + .../LegacyMapboxMigrationService.cs | 124 ++++++++++ .../PersonalProviderContactGate.cs | 211 ++++++++++++++++++ ...PersonalLocationProviderFoundationTests.cs | 47 +++- 4 files changed, 383 insertions(+), 1 deletion(-) create mode 100644 Services/LocationProviders/LegacyMapboxMigrationService.cs create mode 100644 Services/LocationProviders/PersonalProviderContactGate.cs diff --git a/Program.cs b/Program.cs index ca24c25b..e6b291f2 100644 --- a/Program.cs +++ b/Program.cs @@ -489,6 +489,8 @@ static void ConfigureServices(WebApplicationBuilder builder) // Register ApiTokenService with DI container builder.Services.AddScoped(); builder.Services.AddScoped(); + builder.Services.AddScoped(); + builder.Services.AddScoped(); // IRegistrationService as a transient or singleton service builder.Services.AddTransient(); diff --git a/Services/LocationProviders/LegacyMapboxMigrationService.cs b/Services/LocationProviders/LegacyMapboxMigrationService.cs new file mode 100644 index 00000000..655d4f0c --- /dev/null +++ b/Services/LocationProviders/LegacyMapboxMigrationService.cs @@ -0,0 +1,124 @@ +using Microsoft.EntityFrameworkCore; +using Wayfarer.Models; +using Wayfarer.Models.LocationProviders; + +namespace Wayfarer.Services.LocationProviders; + +/// Migrates only the authenticated user's exact legacy Mapbox rows without risking the last readable copy. +public sealed class LegacyMapboxMigrationService( + ApplicationDbContext dbContext, PersonalProviderCredentialService credentials) +{ + /// Converges the current user's legacy state under one transaction and bounded locks. + public async Task MigrateAsync(string userId, CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(userId); + await using var transaction = dbContext.Database.IsRelational() + ? await dbContext.Database.BeginTransactionAsync(cancellationToken) : null; + + var profile = await LockProfileAsync(userId, cancellationToken); + var legacyRows = await LockLegacyRowsAsync(userId, cancellationToken); + var values = legacyRows.Select(item => item.Token!.Trim()).Distinct(StringComparer.Ordinal).ToArray(); + + if (values.Length == 0) + return await CompleteAsync(new(LegacyMapboxMigrationState.None, 0, false), transaction, cancellationToken); + if (profile?.RevokedAt != null) + { + profile.LegacyMigrationState = LegacyMapboxMigrationState.Revoked; + await dbContext.SaveChangesAsync(cancellationToken); + return await CompleteAsync(new(profile.LegacyMigrationState, 0, false), transaction, cancellationToken); + } + if (values.Length > 1) + return await PreserveConflictAsync(profile, userId, transaction, cancellationToken); + + profile ??= PersonalLocationProviderProfile.Create(userId, PersonalLocationProvider.Mapbox); + if (dbContext.Entry(profile).State == EntityState.Detached) + dbContext.Set().Add(profile); + + if (!string.IsNullOrEmpty(profile.ProtectedCredential)) + { + var protectedRead = credentials.Read(profile); + if (!protectedRead.Succeeded) + { + profile.LegacyMigrationState = LegacyMapboxMigrationState.ProtectedCredentialUnavailable; + await dbContext.SaveChangesAsync(cancellationToken); + return await CompleteAsync(new(profile.LegacyMigrationState, 0, false), transaction, cancellationToken); + } + if (!string.Equals(protectedRead.Credential, values[0], StringComparison.Ordinal)) + return await PreserveConflictAsync(profile, userId, transaction, cancellationToken); + } + else + { + credentials.Replace(profile, values[0]); + profile.SetAuthorization(PersonalProviderCapability.Geocoding, true); + profile.SetAuthorization(PersonalProviderCapability.Routing, false); + var selection = await dbContext.Set() + .SingleOrDefaultAsync(item => item.UserId == userId, cancellationToken); + if (selection == null) + { + selection = PersonalLocationProviderSelection.Create(userId); + dbContext.Add(selection); + } + if (selection.GeocodingProviderKey == null) + selection.Select(PersonalProviderCapability.Geocoding, PersonalLocationProvider.Mapbox); + await dbContext.SaveChangesAsync(cancellationToken); + var protectedRead = credentials.Read(profile); + if (!protectedRead.Succeeded || !string.Equals(protectedRead.Credential, values[0], StringComparison.Ordinal)) + { + profile.LegacyMigrationState = LegacyMapboxMigrationState.ProtectedCredentialUnavailable; + await dbContext.SaveChangesAsync(cancellationToken); + return await CompleteAsync(new(profile.LegacyMigrationState, 0, false), transaction, cancellationToken); + } + } + + profile.LegacyMigrationState = LegacyMapboxMigrationState.Migrated; + dbContext.ApiTokens.RemoveRange(legacyRows.Where(item => string.Equals(item.Token?.Trim(), values[0], StringComparison.Ordinal))); + var retired = legacyRows.Count; + await dbContext.SaveChangesAsync(cancellationToken); + return await CompleteAsync(new(profile.LegacyMigrationState, retired, true), transaction, cancellationToken); + } + + private Task LockProfileAsync(string userId, CancellationToken cancellationToken) => + dbContext.Database.IsNpgsql() + ? dbContext.Set().FromSqlInterpolated($$""" + SELECT *, xmin FROM "PersonalLocationProviderProfiles" + WHERE "UserId" = {{userId}} AND "ProviderKey" = 'mapbox' FOR UPDATE + """).SingleOrDefaultAsync(cancellationToken) + : dbContext.Set().SingleOrDefaultAsync( + item => item.UserId == userId && item.ProviderKey == "mapbox", cancellationToken); + + private async Task> LockLegacyRowsAsync(string userId, CancellationToken cancellationToken) + { + var rows = dbContext.Database.IsNpgsql() + ? await dbContext.ApiTokens.FromSqlInterpolated($$""" + SELECT * FROM "ApiTokens" WHERE "UserId" = {{userId}} + AND lower(btrim("Name")) = 'mapbox' AND btrim(COALESCE("Token", '')) <> '' FOR UPDATE + """).ToListAsync(cancellationToken) + : await dbContext.ApiTokens.Where(item => item.UserId == userId && item.Token != null) + .ToListAsync(cancellationToken); + return rows.Where(item => PersonalProviderKeys.IsLegacyMapbox(item.Name) + && !string.IsNullOrWhiteSpace(item.Token)).ToList(); + } + + private async Task PreserveConflictAsync( + PersonalLocationProviderProfile? profile, string userId, + Microsoft.EntityFrameworkCore.Storage.IDbContextTransaction? transaction, CancellationToken cancellationToken) + { + profile ??= PersonalLocationProviderProfile.Create(userId, PersonalLocationProvider.Mapbox); + if (dbContext.Entry(profile).State == EntityState.Detached) dbContext.Add(profile); + profile.LegacyMigrationState = LegacyMapboxMigrationState.Conflict; + await dbContext.SaveChangesAsync(cancellationToken); + return await CompleteAsync(new(profile.LegacyMigrationState, 0, false), transaction, cancellationToken); + } + + private static async Task CompleteAsync( + LegacyMapboxMigrationResult result, Microsoft.EntityFrameworkCore.Storage.IDbContextTransaction? transaction, + CancellationToken cancellationToken) + { + if (transaction != null) await transaction.CommitAsync(cancellationToken); + return result; + } +} + +/// Reports only bounded migration state and counts. +public sealed record LegacyMapboxMigrationResult( + LegacyMapboxMigrationState State, int RetiredLegacyRows, bool ProtectedCredentialReady); diff --git a/Services/LocationProviders/PersonalProviderContactGate.cs b/Services/LocationProviders/PersonalProviderContactGate.cs new file mode 100644 index 00000000..f31780ec --- /dev/null +++ b/Services/LocationProviders/PersonalProviderContactGate.cs @@ -0,0 +1,211 @@ +using Microsoft.EntityFrameworkCore; +using Wayfarer.Models; +using Wayfarer.Models.LocationProviders; + +namespace Wayfarer.Services.LocationProviders; + +/// Owns the lowest shared credential-authority and durable usage-admission seam before provider HTTP. +public sealed class PersonalProviderContactGate( + ApplicationDbContext dbContext, PersonalProviderCredentialService credentials, + LegacyMapboxMigrationService legacyMigration, IConfiguration configuration) +{ + /// Resolves current authority and durably admits the caller's validated provider-native cost. + public async Task AdmitAsync( + string userId, PersonalProviderCapability capability, PersonalProviderProduct product, + int cost, CancellationToken cancellationToken = default) + { + if (cost <= 0) return PersonalProviderAdmission.Rejected(PersonalProviderAdmissionCategory.InvalidCost); + if (capability == PersonalProviderCapability.Geocoding) + await legacyMigration.MigrateAsync(userId, cancellationToken); + + var authority = await ResolveAsync(userId, capability, cancellationToken); + if (!authority.Succeeded) return PersonalProviderAdmission.Rejected(authority.Category); + if (!ProductMatches(authority.ProviderKey!, capability, product)) + return PersonalProviderAdmission.Rejected(PersonalProviderAdmissionCategory.UnsupportedProduct); + + var admitted = authority.ProviderKey == "geoapify" + ? await AdmitGeoapifyAsync(userId, product, cost, cancellationToken) + : await AdmitMapboxAsync(userId, product, cost, cancellationToken); + if (!admitted.Succeeded) return admitted; + + var snapshot = new PersonalProviderAuthoritySnapshot(userId, authority.ProviderKey!, capability, + authority.Credential!, authority.CredentialGeneration, authority.CapabilityGeneration, + authority.SelectionGeneration); + return new(PersonalProviderAdmissionCategory.Admitted, snapshot, admitted.Usage); + } + + /// Revalidates bounded authority immediately before contact and result persistence. + public async Task IsCurrentAsync( + PersonalProviderAuthoritySnapshot snapshot, CancellationToken cancellationToken = default) + { + var current = await ResolveAsync(snapshot.UserId, snapshot.Capability, cancellationToken); + return current.Succeeded && current.ProviderKey == snapshot.ProviderKey + && current.CredentialGeneration == snapshot.CredentialGeneration + && current.CapabilityGeneration == snapshot.CapabilityGeneration + && current.SelectionGeneration == snapshot.SelectionGeneration; + } + + private async Task ResolveAsync( + string userId, PersonalProviderCapability capability, CancellationToken cancellationToken) + { + var selection = await dbContext.Set().AsNoTracking() + .SingleOrDefaultAsync(item => item.UserId == userId, cancellationToken); + var providerKey = capability == PersonalProviderCapability.Geocoding + ? selection?.GeocodingProviderKey : selection?.RoutingProviderKey; + if (providerKey == null) return ResolvedAuthority.Fail(PersonalProviderAdmissionCategory.NoProviderSelected); + if (providerKey is not ("geoapify" or "mapbox")) + return ResolvedAuthority.Fail(PersonalProviderAdmissionCategory.UnsupportedProvider); + + var profile = await dbContext.Set().AsNoTracking() + .SingleOrDefaultAsync(item => item.UserId == userId && item.ProviderKey == providerKey, cancellationToken); + if (profile == null || profile.RevokedAt != null || !profile.IsAuthorized(capability)) + return ResolvedAuthority.Fail(PersonalProviderAdmissionCategory.Unauthorized); + var read = credentials.Read(profile); + if (!read.Succeeded) return ResolvedAuthority.Fail(PersonalProviderAdmissionCategory.CredentialUnavailable); + + var verified = capability == PersonalProviderCapability.Geocoding + ? profile.GeocodingVerification == PersonalProviderVerification.Verified + && profile.GeocodingVerifiedCredentialGeneration == profile.CredentialGeneration + && profile.GeocodingVerifiedConfigurationGeneration == profile.GeocodingGeneration + : profile.RoutingVerification == PersonalProviderVerification.Verified + && profile.RoutingVerifiedCredentialGeneration == profile.CredentialGeneration + && profile.RoutingVerifiedConfigurationGeneration == profile.RoutingGeneration; + if (!verified) return ResolvedAuthority.Fail(PersonalProviderAdmissionCategory.Unverified); + return new(true, PersonalProviderAdmissionCategory.Admitted, providerKey, read.Credential, + profile.CredentialGeneration, + capability == PersonalProviderCapability.Geocoding ? profile.GeocodingGeneration : profile.RoutingGeneration, + capability == PersonalProviderCapability.Geocoding + ? selection!.GeocodingSelectionGeneration : selection!.RoutingSelectionGeneration); + } + + private async Task AdmitGeoapifyAsync( + string userId, PersonalProviderProduct product, int credits, CancellationToken cancellationToken) + { + await using var transaction = dbContext.Database.IsRelational() + ? await dbContext.Database.BeginTransactionAsync(cancellationToken) : null; + var guard = await LockGeoapifyGuardAsync(userId, cancellationToken); + var now = dbContext.Database.IsNpgsql() + ? await dbContext.Database.SqlQuery($"SELECT clock_timestamp() AS \"Value\"").SingleAsync(cancellationToken) + : DateTimeOffset.UtcNow; + var cutoff = now.AddHours(-24); + var used = await dbContext.Set() + .Where(item => item.UserId == userId && item.AdmittedAt > cutoff).SumAsync(item => (int?)item.Credits, cancellationToken) ?? 0; + if (guard.Enabled && (long)used + credits > guard.CreditLimit) + return await CompleteAsync(PersonalProviderAdmission.Rejected(PersonalProviderAdmissionCategory.Exhausted, + new(used, guard.CreditLimit, "credits", cutoff, null)), transaction, false, cancellationToken); + + dbContext.Set().Add(new() + { UserId = userId, Credits = credits, Product = product, AdmittedAt = now }); + await dbContext.Set() + .Where(item => item.UserId == userId && item.AdmittedAt <= cutoff).ExecuteDeleteAsync(cancellationToken); + await dbContext.SaveChangesAsync(cancellationToken); + return await CompleteAsync(new(PersonalProviderAdmissionCategory.Admitted, null, + new(used + credits, guard.CreditLimit, "credits", cutoff, null)), transaction, true, cancellationToken); + } + + private async Task LockGeoapifyGuardAsync(string userId, CancellationToken cancellationToken) + { + if (dbContext.Database.IsNpgsql()) + { + var defaultLimit = configuration.GetValue("LocationProviders:Geoapify:RollingCreditLimit", 2500); + await dbContext.Database.ExecuteSqlInterpolatedAsync($$""" + INSERT INTO "GeoapifyUsageGuards" ("UserId", "Enabled", "CreditLimit") + VALUES ({{userId}}, TRUE, {{defaultLimit}}) ON CONFLICT ("UserId") DO NOTHING + """, cancellationToken); + return await dbContext.Set().FromSqlInterpolated($$""" + SELECT *, xmin FROM "GeoapifyUsageGuards" WHERE "UserId" = {{userId}} FOR UPDATE + """).SingleAsync(cancellationToken); + } + var guard = await dbContext.Set().SingleOrDefaultAsync(item => item.UserId == userId, cancellationToken); + if (guard != null) return guard; + guard = new() { UserId = userId }; + dbContext.Add(guard); + await dbContext.SaveChangesAsync(cancellationToken); + return guard; + } + + private async Task AdmitMapboxAsync( + string userId, PersonalProviderProduct product, int cost, CancellationToken cancellationToken) + { + await using var transaction = dbContext.Database.IsRelational() + ? await dbContext.Database.BeginTransactionAsync(cancellationToken) : null; + var meter = await LockMapboxMeterAsync(userId, product, cancellationToken); + var today = dbContext.Database.IsNpgsql() + ? DateOnly.FromDateTime(await dbContext.Database.SqlQuery($"SELECT (clock_timestamp() AT TIME ZONE 'UTC') AS \"Value\"").SingleAsync(cancellationToken)) + : DateOnly.FromDateTime(DateTime.UtcNow); + var cycle = new DateOnly(today.Year, today.Month, 1); + if (meter.CycleStart != cycle) { meter.CycleStart = cycle; meter.AdmittedCount = 0; } + if (meter.Enabled && (long)meter.AdmittedCount + cost > meter.Limit) + return await CompleteAsync(PersonalProviderAdmission.Rejected(PersonalProviderAdmissionCategory.Exhausted, + new(meter.AdmittedCount, meter.Limit, "contacts", null, cycle)), transaction, false, cancellationToken); + meter.AdmittedCount = checked(meter.AdmittedCount + cost); + await dbContext.SaveChangesAsync(cancellationToken); + return await CompleteAsync(new(PersonalProviderAdmissionCategory.Admitted, null, + new(meter.AdmittedCount, meter.Limit, "contacts", null, cycle)), transaction, true, cancellationToken); + } + + private async Task LockMapboxMeterAsync( + string userId, PersonalProviderProduct product, CancellationToken cancellationToken) + { + var key = product == PersonalProviderProduct.PermanentGeocoding ? "PermanentGeocodingLimit" : "DirectionsLimit"; + var limit = configuration.GetValue($"LocationProviders:Mapbox:{key}", 1000); + if (dbContext.Database.IsNpgsql()) + { + await dbContext.Database.ExecuteSqlInterpolatedAsync($$""" + INSERT INTO "MapboxProductMeters" ("UserId", "Product", "Enabled", "Limit", "CycleStart", "AdmittedCount") + VALUES ({{userId}}, {{(int)product}}, TRUE, {{limit}}, DATE '1970-01-01', 0) + ON CONFLICT ("UserId", "Product") DO NOTHING + """, cancellationToken); + return await dbContext.Set().FromSqlInterpolated($$""" + SELECT *, xmin FROM "MapboxProductMeters" + WHERE "UserId" = {{userId}} AND "Product" = {{(int)product}} FOR UPDATE + """).SingleAsync(cancellationToken); + } + var meter = await dbContext.Set().SingleOrDefaultAsync( + item => item.UserId == userId && item.Product == product, cancellationToken); + if (meter != null) return meter; + meter = new() { UserId = userId, Product = product, Limit = limit, CycleStart = new(1970, 1, 1) }; + dbContext.Add(meter); await dbContext.SaveChangesAsync(cancellationToken); return meter; + } + + private static bool ProductMatches(string provider, PersonalProviderCapability capability, PersonalProviderProduct product) => + provider == "geoapify" && ((capability == PersonalProviderCapability.Geocoding && product == PersonalProviderProduct.Geocoding) + || (capability == PersonalProviderCapability.Routing && product == PersonalProviderProduct.Routing)) + || provider == "mapbox" && ((capability == PersonalProviderCapability.Geocoding && product == PersonalProviderProduct.PermanentGeocoding) + || (capability == PersonalProviderCapability.Routing && product == PersonalProviderProduct.Directions)); + + private static async Task CompleteAsync( + PersonalProviderAdmission result, Microsoft.EntityFrameworkCore.Storage.IDbContextTransaction? transaction, + bool commit, CancellationToken cancellationToken) + { + if (transaction != null) + { if (commit) await transaction.CommitAsync(cancellationToken); else await transaction.RollbackAsync(cancellationToken); } + return result; + } + + private sealed record ResolvedAuthority(bool Succeeded, PersonalProviderAdmissionCategory Category, + string? ProviderKey, string? Credential, int CredentialGeneration, int CapabilityGeneration, int SelectionGeneration) + { + public static ResolvedAuthority Fail(PersonalProviderAdmissionCategory category) => new(false, category, null, null, 0, 0, 0); + } +} + +/// Identifies bounded admission outcomes safe for diagnostics. +public enum PersonalProviderAdmissionCategory +{ Admitted, InvalidCost, NoProviderSelected, UnsupportedProvider, UnsupportedProduct, Unauthorized, Unverified, CredentialUnavailable, Exhausted } + +/// Contains server-internal immutable contact authority; it must never be serialized. +public sealed record PersonalProviderAuthoritySnapshot(string UserId, string ProviderKey, + PersonalProviderCapability Capability, string Credential, int CredentialGeneration, + int CapabilityGeneration, int SelectionGeneration); + +/// Contains only bounded usage status. +public sealed record PersonalProviderUsageStatus(int Used, int Limit, string Unit, DateTimeOffset? RollingCutoff, DateOnly? CycleStart); + +/// Returns bounded rejection or admitted server authority. +public sealed record PersonalProviderAdmission(PersonalProviderAdmissionCategory Category, + PersonalProviderAuthoritySnapshot? Authority, PersonalProviderUsageStatus? Usage) +{ + public bool Succeeded => Category == PersonalProviderAdmissionCategory.Admitted; + public static PersonalProviderAdmission Rejected(PersonalProviderAdmissionCategory category, PersonalProviderUsageStatus? usage = null) => new(category, null, usage); +} diff --git a/tests/Wayfarer.Tests/Services/PersonalLocationProviderFoundationTests.cs b/tests/Wayfarer.Tests/Services/PersonalLocationProviderFoundationTests.cs index a93f7a56..f16c5a47 100644 --- a/tests/Wayfarer.Tests/Services/PersonalLocationProviderFoundationTests.cs +++ b/tests/Wayfarer.Tests/Services/PersonalLocationProviderFoundationTests.cs @@ -1,12 +1,15 @@ using Microsoft.AspNetCore.DataProtection; +using Microsoft.EntityFrameworkCore; +using Wayfarer.Models; using Wayfarer.Models.LocationProviders; using Wayfarer.Services.LocationProviders; +using Wayfarer.Tests.Infrastructure; using Xunit; namespace Wayfarer.Tests.Services; /// Defines the shared personal-provider authority required by issues 500 through 502. -public sealed class PersonalLocationProviderFoundationTests +public sealed class PersonalLocationProviderFoundationTests : TestBase { [Fact] public void CredentialOwner_ProtectsProviderProfileCredential() @@ -76,4 +79,46 @@ public void MapboxAdmission_UsesIndependentProductCounters() Assert.False(ledger.TryAdmitMapbox(cycle, PersonalProviderProduct.PermanentGeocoding, 1, 1)); Assert.True(ledger.TryAdmitMapbox(cycle, PersonalProviderProduct.Directions, 1, 1)); } + + [Fact] + public async Task LegacyMigration_ProtectsBeforeRetiringAndPreservesUnrelatedTokens() + { + var db = CreateDbContext(); + var user = TestDataFixtures.CreateUser(id: "legacy-user", username: "legacy"); + db.Users.Add(user); + db.ApiTokens.AddRange( + new ApiToken { Id = 8001, Name = " MapBOX ", Token = "legacy-key", UserId = user.Id, User = user }, + new ApiToken { Id = 8002, Name = "mobile", TokenHash = "hash", UserId = user.Id, User = user }); + await db.SaveChangesAsync(); + var owner = new PersonalProviderCredentialService(new EphemeralDataProtectionProvider()); + + var result = await new LegacyMapboxMigrationService(db, owner).MigrateAsync(user.Id); + + var profile = await db.PersonalLocationProviderProfiles.SingleAsync(); + Assert.True(result.ProtectedCredentialReady); + Assert.Equal("legacy-key", owner.Read(profile).Credential); + Assert.True(profile.GeocodingAuthorized); + Assert.False(profile.RoutingAuthorized); + Assert.DoesNotContain(await db.ApiTokens.ToListAsync(), item => PersonalProviderKeys.IsLegacyMapbox(item.Name)); + Assert.Contains(await db.ApiTokens.ToListAsync(), item => item.Name == "mobile"); + } + + [Fact] + public async Task LegacyMigration_PreservesDistinctRecognizedValuesAndFailsClosed() + { + var db = CreateDbContext(); + var user = TestDataFixtures.CreateUser(id: "conflict-user", username: "conflict"); + db.Users.Add(user); + db.ApiTokens.AddRange( + new ApiToken { Id = 8101, Name = "Mapbox", Token = "first", UserId = user.Id, User = user }, + new ApiToken { Id = 8102, Name = "mapBOX", Token = "second", UserId = user.Id, User = user }); + await db.SaveChangesAsync(); + + var result = await new LegacyMapboxMigrationService(db, + new PersonalProviderCredentialService(new EphemeralDataProtectionProvider())).MigrateAsync(user.Id); + + Assert.Equal(LegacyMapboxMigrationState.Conflict, result.State); + Assert.Equal(2, await db.ApiTokens.CountAsync()); + Assert.Null((await db.PersonalLocationProviderProfiles.SingleAsync()).ProtectedCredential); + } } From 637ea4ea9598422fc27b830d376731c5fde4acec Mon Sep 17 00:00:00 2001 From: Stef Kariotidis Date: Sun, 23 Aug 2026 12:28:36 +0300 Subject: [PATCH 04/13] test(providers): prove durable provider usage admission --- .../PersonalProviderUsagePostgresTests.cs | 108 ++++++++++++++++++ 1 file changed, 108 insertions(+) create mode 100644 tests/Wayfarer.Tests/Models/PersonalProviderUsagePostgresTests.cs diff --git a/tests/Wayfarer.Tests/Models/PersonalProviderUsagePostgresTests.cs b/tests/Wayfarer.Tests/Models/PersonalProviderUsagePostgresTests.cs new file mode 100644 index 00000000..d257d596 --- /dev/null +++ b/tests/Wayfarer.Tests/Models/PersonalProviderUsagePostgresTests.cs @@ -0,0 +1,108 @@ +using Microsoft.AspNetCore.DataProtection; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; +using Wayfarer.Models.LocationProviders; +using Wayfarer.Services.LocationProviders; +using Wayfarer.Tests.Infrastructure; +using Xunit; + +namespace Wayfarer.Tests.Models; + +/// Proves provider-native usage authority on guarded PostgreSQL. +[Collection(PostgresEnvironmentEvidenceTestCollection.Name)] +public sealed class PersonalProviderUsagePostgresTests(PostgresImportTestFixture fixture) +{ + [PostgresFact] + public async Task GeoapifyConcurrentLastCredit_HasExactlyOneWinnerAcrossContexts() + { + fixture.RequireAvailable(); + var user = await fixture.CreateUserAsync(); + var protection = new EphemeralDataProtectionProvider(); + await SeedVerifiedProfileAsync(user.Id, PersonalLocationProvider.Geoapify, + PersonalProviderCapability.Geocoding, protection); + await using (var setup = fixture.CreateContext()) + { + setup.GeoapifyUsageGuards.Add(new() { UserId = user.Id, Enabled = true, CreditLimit = 1 }); + await setup.SaveChangesAsync(); + } + + await using var firstContext = fixture.CreateContext(); + await using var secondContext = fixture.CreateContext(); + var first = Gate(firstContext, protection).AdmitAsync(user.Id, PersonalProviderCapability.Geocoding, + PersonalProviderProduct.Geocoding, 1); + var second = Gate(secondContext, protection).AdmitAsync(user.Id, PersonalProviderCapability.Geocoding, + PersonalProviderProduct.Geocoding, 1); + var results = await Task.WhenAll(first, second); + + Assert.Single(results, item => item.Succeeded); + Assert.Single(results, item => item.Category == PersonalProviderAdmissionCategory.Exhausted); + await using var verify = fixture.CreateContext(); + Assert.Equal(1, await verify.GeoapifyUsageAdmissions.Where(item => item.UserId == user.Id).SumAsync(item => item.Credits)); + } + + [PostgresFact] + public async Task MapboxProducts_ExhaustAndRollIndependently() + { + fixture.RequireAvailable(); + var user = await fixture.CreateUserAsync(); + var protection = new EphemeralDataProtectionProvider(); + await SeedVerifiedProfileAsync(user.Id, PersonalLocationProvider.Mapbox, + PersonalProviderCapability.Geocoding, protection, alsoRouting: true); + await using (var setup = fixture.CreateContext()) + { + setup.MapboxProductMeters.AddRange( + new() { UserId = user.Id, Product = PersonalProviderProduct.PermanentGeocoding, Enabled = true, Limit = 1, CycleStart = new(1970, 1, 1) }, + new() { UserId = user.Id, Product = PersonalProviderProduct.Directions, Enabled = true, Limit = 1, CycleStart = new(1970, 1, 1) }); + await setup.SaveChangesAsync(); + } + + await using var context = fixture.CreateContext(); + var gate = Gate(context, protection); + Assert.True((await gate.AdmitAsync(user.Id, PersonalProviderCapability.Geocoding, + PersonalProviderProduct.PermanentGeocoding, 1)).Succeeded); + Assert.Equal(PersonalProviderAdmissionCategory.Exhausted, (await gate.AdmitAsync(user.Id, + PersonalProviderCapability.Geocoding, PersonalProviderProduct.PermanentGeocoding, 1)).Category); + Assert.True((await gate.AdmitAsync(user.Id, PersonalProviderCapability.Routing, + PersonalProviderProduct.Directions, 1)).Succeeded); + } + + private async Task SeedVerifiedProfileAsync(string userId, PersonalLocationProvider provider, + PersonalProviderCapability capability, IDataProtectionProvider protection, bool alsoRouting = false) + { + await using var context = fixture.CreateContext(); + var owner = new PersonalProviderCredentialService(protection); + var profile = PersonalLocationProviderProfile.Create(userId, provider); + owner.Replace(profile, "test-provider-key"); + Verify(profile, capability); + if (alsoRouting) Verify(profile, PersonalProviderCapability.Routing); + var selection = PersonalLocationProviderSelection.Create(userId); + selection.Select(capability, provider); + if (alsoRouting) selection.Select(PersonalProviderCapability.Routing, provider); + context.AddRange(profile, selection); + await context.SaveChangesAsync(); + } + + private static void Verify(PersonalLocationProviderProfile profile, PersonalProviderCapability capability) + { + profile.SetAuthorization(capability, true); + if (capability == PersonalProviderCapability.Geocoding) + { + profile.GeocodingVerification = PersonalProviderVerification.Verified; + profile.GeocodingVerifiedCredentialGeneration = profile.CredentialGeneration; + profile.GeocodingVerifiedConfigurationGeneration = profile.GeocodingGeneration; + } + else + { + profile.RoutingVerification = PersonalProviderVerification.Verified; + profile.RoutingVerifiedCredentialGeneration = profile.CredentialGeneration; + profile.RoutingVerifiedConfigurationGeneration = profile.RoutingGeneration; + } + } + + private static PersonalProviderContactGate Gate(Wayfarer.Models.ApplicationDbContext context, IDataProtectionProvider protection) + { + var owner = new PersonalProviderCredentialService(protection); + var config = new ConfigurationBuilder().AddInMemoryCollection().Build(); + return new(context, owner, new LegacyMapboxMigrationService(context, owner), config); + } +} From bfe34bbc1ace711b90b289e56e44dd42282ae363 Mon Sep 17 00:00:00 2001 From: Stef Kariotidis Date: Sun, 23 Aug 2026 12:28:43 +0300 Subject: [PATCH 05/13] feat(providers): add masked settings and close legacy exposures --- Areas/Admin/Views/ApiToken/Index.cshtml | 2 +- Areas/Api/Controllers/LocationController.cs | 10 +- Areas/Manager/Views/ApiToken/Index.cshtml | 2 +- Areas/User/Controllers/ApiTokenController.cs | 7 +- .../LocationProviderSettingsController.cs | 144 ++++++++++++++++++ .../LocationProviderSettingsViewModel.cs | 40 +++++ Areas/User/Views/ApiToken/Index.cshtml | 10 +- .../LocationProviderSettings/Index.cshtml | 44 ++++++ Areas/User/Views/Settings/Index.cshtml | 1 + Models/ApiToken.cs | 5 +- Models/ApplicationDbContext.cs | 9 +- .../LegacyMapboxMigrationService.cs | 2 +- Util/ApiTokenService.cs | 6 +- ...PersonalLocationProviderFoundationTests.cs | 27 +++- 14 files changed, 283 insertions(+), 26 deletions(-) create mode 100644 Areas/User/Controllers/LocationProviderSettingsController.cs create mode 100644 Areas/User/LocationProviderModels/LocationProviderSettingsViewModel.cs create mode 100644 Areas/User/Views/LocationProviderSettings/Index.cshtml diff --git a/Areas/Admin/Views/ApiToken/Index.cshtml b/Areas/Admin/Views/ApiToken/Index.cshtml index 83718719..5453cb97 100644 --- a/Areas/Admin/Views/ApiToken/Index.cshtml +++ b/Areas/Admin/Views/ApiToken/Index.cshtml @@ -31,7 +31,7 @@

Created At: @token.CreatedAt.ToString("yyyy-MM-dd HH:mm:ss")

Token: - @token.Token + @token.DisplayToken

diff --git a/Areas/Api/Controllers/LocationController.cs b/Areas/Api/Controllers/LocationController.cs index 5b84e8d8..2f994400 100644 --- a/Areas/Api/Controllers/LocationController.cs +++ b/Areas/Api/Controllers/LocationController.cs @@ -196,8 +196,7 @@ public async Task CheckIn([FromBody] GpsLoggerLocationDto dto) var locationInfo = await _reverseGeocodingService.GetReverseGeocodingDataAsync( dto.Latitude, dto.Longitude, apiToken.Token ?? string.Empty, apiToken.Name ?? string.Empty); - _logger.LogInformation( - $"Check-in, user has mapbox Api token, we got reverse geocoding data: {locationInfo.FullAddress}"); + _logger.LogInformation("Check-in reverse geocoding completed."); location.FullAddress = locationInfo.FullAddress; location.Address = locationInfo.Address; @@ -675,8 +674,7 @@ await _placeVisitDetectionService.ProcessPingAsync( var locationInfo = await _reverseGeocodingService.GetReverseGeocodingDataAsync( dto.Latitude, dto.Longitude, apiToken.Token, apiToken.Name); - _logger.LogInformation( - $"Log-location, user has mapbox Api token, we got reverse geocoding data: {locationInfo.FullAddress}"); + _logger.LogInformation("Log-location reverse geocoding completed."); location.FullAddress = locationInfo.FullAddress; location.Address = locationInfo.Address; @@ -1055,9 +1053,7 @@ public async Task Update(int id, [FromBody] LocationUpdateRequest var locationInfo = await _reverseGeocodingService.GetReverseGeocodingDataAsync( lat, lon, apiToken.Token, apiToken.Name); - _logger.LogInformation( - "Update: reverse geocoding refreshed for location {LocationId}: {Address}", - id, locationInfo.FullAddress); + _logger.LogInformation("Update reverse geocoding completed for location {LocationId}.", id); location.FullAddress = locationInfo.FullAddress; location.Address = locationInfo.Address; diff --git a/Areas/Manager/Views/ApiToken/Index.cshtml b/Areas/Manager/Views/ApiToken/Index.cshtml index 55b665dc..53636ca5 100644 --- a/Areas/Manager/Views/ApiToken/Index.cshtml +++ b/Areas/Manager/Views/ApiToken/Index.cshtml @@ -25,7 +25,7 @@

Created At: @token.CreatedAt.ToString("yyyy-MM-dd HH:mm:ss")

Token: - @token.Token + @token.DisplayToken

diff --git a/Areas/User/Controllers/ApiTokenController.cs b/Areas/User/Controllers/ApiTokenController.cs index 780d76c6..b6dd808a 100644 --- a/Areas/User/Controllers/ApiTokenController.cs +++ b/Areas/User/Controllers/ApiTokenController.cs @@ -96,6 +96,11 @@ public async Task StoreThirdPartyToken(string thirdPartyServiceNa SetAlert("User not authenticated.", "danger"); return RedirectToAction("Index", "Home", new { area = "" }); } + if (Wayfarer.Models.LocationProviders.PersonalProviderKeys.IsLegacyMapbox(thirdPartyServiceName)) + { + SetAlert("Configure Mapbox under Personal location providers; provider credentials are protected there.", "warning"); + return RedirectToAction("Index", "LocationProviderSettings"); + } // Check if token exists for current user before creating it bool exists = await _dbContext.ApiTokens.AnyAsync(t => @@ -203,4 +208,4 @@ public async Task DeleteConfirmed(int tokenId) } } } -} \ No newline at end of file +} diff --git a/Areas/User/Controllers/LocationProviderSettingsController.cs b/Areas/User/Controllers/LocationProviderSettingsController.cs new file mode 100644 index 00000000..0344c48e --- /dev/null +++ b/Areas/User/Controllers/LocationProviderSettingsController.cs @@ -0,0 +1,144 @@ +using System.Security.Claims; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using Wayfarer.Areas.User.LocationProviderModels; +using Wayfarer.Models; +using Wayfarer.Models.LocationProviders; +using Wayfarer.Services.LocationProviders; + +namespace Wayfarer.Areas.User.Controllers; + +/// Manages only the authenticated user's protected provider profiles, selections, and safety guards. +[Area("User"), Authorize(Roles = "User")] +public sealed class LocationProviderSettingsController( + ApplicationDbContext dbContext, PersonalProviderCredentialService credentials, + LegacyMapboxMigrationService migration) : Controller +{ + /// Displays masked provider authority and provider-native usage status. + public async Task Index(CancellationToken cancellationToken) + { + var userId = User.FindFirstValue(ClaimTypes.NameIdentifier); + if (userId == null) return Challenge(); + await migration.MigrateAsync(userId, cancellationToken); + return View(await BuildAsync(userId, cancellationToken)); + } + + /// Replaces a credential only when nonblank and changes explicit capability selections independently. + [HttpPost, ValidateAntiForgeryToken] + public async Task SaveProfile(LocationProviderProfileInput input, CancellationToken cancellationToken) + { + var userId = User.FindFirstValue(ClaimTypes.NameIdentifier); + if (userId == null) return Challenge(); + if (!ModelState.IsValid) return View("Index", await BuildAsync(userId, cancellationToken)); + var provider = ParseProvider(input.ProviderKey); + var key = PersonalProviderKeys.Key(provider); + var profile = await dbContext.PersonalLocationProviderProfiles + .SingleOrDefaultAsync(item => item.UserId == userId && item.ProviderKey == key, cancellationToken) + ?? PersonalLocationProviderProfile.Create(userId, provider); + if (dbContext.Entry(profile).State == EntityState.Detached) dbContext.Add(profile); + if (!string.IsNullOrWhiteSpace(input.ReplacementCredential)) credentials.Replace(profile, input.ReplacementCredential); + profile.SetAuthorization(PersonalProviderCapability.Geocoding, input.GeocodingAuthorized); + profile.SetAuthorization(PersonalProviderCapability.Routing, input.RoutingAuthorized); + + var selection = await dbContext.PersonalLocationProviderSelections.SingleOrDefaultAsync( + item => item.UserId == userId, cancellationToken) ?? PersonalLocationProviderSelection.Create(userId); + if (dbContext.Entry(selection).State == EntityState.Detached) dbContext.Add(selection); + if (input.ActiveForGeocoding) selection.Select(PersonalProviderCapability.Geocoding, provider); + else if (selection.GeocodingProviderKey == key) selection.Select(PersonalProviderCapability.Geocoding, null); + if (input.ActiveForRouting) selection.Select(PersonalProviderCapability.Routing, provider); + else if (selection.RoutingProviderKey == key) selection.Select(PersonalProviderCapability.Routing, null); + await dbContext.SaveChangesAsync(cancellationToken); + return RedirectToAction(nameof(Index)); + } + + /// Explicitly revokes one credential without deleting profiles, usage, or domain data. + [HttpPost, ValidateAntiForgeryToken] + public async Task Revoke(string providerKey, bool confirmed, CancellationToken cancellationToken) + { + var userId = User.FindFirstValue(ClaimTypes.NameIdentifier); + if (userId == null) return Challenge(); + if (!confirmed) return RedirectToAction(nameof(Index)); + var profile = await dbContext.PersonalLocationProviderProfiles.SingleOrDefaultAsync( + item => item.UserId == userId && item.ProviderKey == providerKey, cancellationToken); + if (profile != null) { credentials.Revoke(profile); await dbContext.SaveChangesAsync(cancellationToken); } + return RedirectToAction(nameof(Index)); + } + + /// Updates only a provider-native guard; lowering never deletes or resets usage. + [HttpPost, ValidateAntiForgeryToken] + public async Task SaveGuard(LocationProviderGuardInput input, CancellationToken cancellationToken) + { + var userId = User.FindFirstValue(ClaimTypes.NameIdentifier); + if (userId == null) return Challenge(); + if (!ModelState.IsValid) return RedirectToAction(nameof(Index)); + if (input.GuardKey == "geoapify") + { + var guard = await dbContext.GeoapifyUsageGuards.SingleOrDefaultAsync(item => item.UserId == userId, cancellationToken) + ?? new GeoapifyUsageGuard { UserId = userId }; + if (dbContext.Entry(guard).State == EntityState.Detached) dbContext.Add(guard); + guard.Enabled = input.Enabled; guard.CreditLimit = input.Limit; + } + else + { + var product = input.GuardKey == "mapbox-permanent" + ? PersonalProviderProduct.PermanentGeocoding : PersonalProviderProduct.Directions; + var meter = await dbContext.MapboxProductMeters.SingleOrDefaultAsync( + item => item.UserId == userId && item.Product == product, cancellationToken) + ?? new MapboxProductMeter { UserId = userId, Product = product, CycleStart = new(1970, 1, 1) }; + if (dbContext.Entry(meter).State == EntityState.Detached) dbContext.Add(meter); + meter.Enabled = input.Enabled; meter.Limit = input.Limit; + } + await dbContext.SaveChangesAsync(cancellationToken); + return RedirectToAction(nameof(Index)); + } + + private async Task BuildAsync(string userId, CancellationToken cancellationToken) + { + var profiles = await dbContext.PersonalLocationProviderProfiles.AsNoTracking() + .Where(item => item.UserId == userId).ToListAsync(cancellationToken); + var selection = await dbContext.PersonalLocationProviderSelections.AsNoTracking() + .SingleOrDefaultAsync(item => item.UserId == userId, cancellationToken); + var geoGuard = await dbContext.GeoapifyUsageGuards.AsNoTracking().SingleOrDefaultAsync(item => item.UserId == userId, cancellationToken); + var cutoff = DateTimeOffset.UtcNow.AddHours(-24); + var geoUsed = await dbContext.GeoapifyUsageAdmissions.AsNoTracking() + .Where(item => item.UserId == userId && item.AdmittedAt > cutoff).SumAsync(item => (int?)item.Credits, cancellationToken) ?? 0; + var meters = await dbContext.MapboxProductMeters.AsNoTracking().Where(item => item.UserId == userId).ToListAsync(cancellationToken); + var views = new[] { PersonalLocationProvider.Geoapify, PersonalLocationProvider.Mapbox }.Select(provider => + BuildProfile(provider, profiles, geoGuard, geoUsed, meters)).ToArray(); + return new() + { + Profiles = views, ActiveGeocodingProvider = selection?.GeocodingProviderKey, + ActiveRoutingProvider = selection?.RoutingProviderKey, + LegacyMigrationState = profiles.SingleOrDefault(item => item.ProviderKey == "mapbox")?.LegacyMigrationState ?? LegacyMapboxMigrationState.None + }; + } + + private static LocationProviderProfileViewModel BuildProfile(PersonalLocationProvider provider, + IReadOnlyCollection profiles, GeoapifyUsageGuard? geoGuard, int geoUsed, + IReadOnlyCollection meters) + { + var key = PersonalProviderKeys.Key(provider); + var profile = profiles.SingleOrDefault(item => item.ProviderKey == key); + if (provider == PersonalLocationProvider.Geoapify) + { + var limit = geoGuard?.CreditLimit ?? 2500; + return new(key, "Geoapify", profile?.ProtectedCredential != null && profile.RevokedAt == null, "••••••••••••••••", + profile?.GeocodingAuthorized == true, profile?.GeocodingVerification ?? 0, + profile?.RoutingAuthorized == true, profile?.RoutingVerification ?? 0, + geoGuard?.Enabled ?? true, limit, geoUsed, "credits", + "Wayfarer rolling 24-hour shared geocoding/routing window", (geoGuard?.Enabled ?? true) && geoUsed >= limit); + } + var permanent = meters.SingleOrDefault(item => item.Product == PersonalProviderProduct.PermanentGeocoding); + var directions = meters.SingleOrDefault(item => item.Product == PersonalProviderProduct.Directions); + return new(key, "Mapbox", profile?.ProtectedCredential != null && profile.RevokedAt == null, "••••••••••••••••", + profile?.GeocodingAuthorized == true, profile?.GeocodingVerification ?? 0, + profile?.RoutingAuthorized == true, profile?.RoutingVerification ?? 0, + permanent?.Enabled ?? true, permanent?.Limit ?? 1000, permanent?.AdmittedCount ?? 0, "Permanent Geocoding contacts", + "Wayfarer UTC calendar-month Permanent Geocoding safety cycle", permanent?.Enabled == true && permanent.AdmittedCount >= permanent.Limit, + directions?.Enabled ?? true, directions?.Limit ?? 1000, directions?.AdmittedCount ?? 0); + } + + private static PersonalLocationProvider ParseProvider(string key) => key switch + { "geoapify" => PersonalLocationProvider.Geoapify, "mapbox" => PersonalLocationProvider.Mapbox, _ => throw new ArgumentOutOfRangeException(nameof(key)) }; +} diff --git a/Areas/User/LocationProviderModels/LocationProviderSettingsViewModel.cs b/Areas/User/LocationProviderModels/LocationProviderSettingsViewModel.cs new file mode 100644 index 00000000..cb03457a --- /dev/null +++ b/Areas/User/LocationProviderModels/LocationProviderSettingsViewModel.cs @@ -0,0 +1,40 @@ +using System.ComponentModel.DataAnnotations; +using Wayfarer.Models.LocationProviders; + +namespace Wayfarer.Areas.User.LocationProviderModels; + +/// Contains only masked personal provider settings presentation. +public sealed class LocationProviderSettingsViewModel +{ + public IReadOnlyList Profiles { get; init; } = []; + public string? ActiveGeocodingProvider { get; init; } + public string? ActiveRoutingProvider { get; init; } + public LegacyMapboxMigrationState LegacyMigrationState { get; init; } +} + +/// Presents bounded profile, capability, and provider-native usage state. +public sealed record LocationProviderProfileViewModel( + string ProviderKey, string DisplayName, bool CredentialConfigured, string Mask, + bool GeocodingAuthorized, PersonalProviderVerification GeocodingVerification, + bool RoutingAuthorized, PersonalProviderVerification RoutingVerification, + bool GuardEnabled, int Limit, int Used, string Unit, string WindowExplanation, bool Exhausted, + bool? DirectionsGuardEnabled = null, int? DirectionsLimit = null, int? DirectionsUsed = null); + +/// Accepts explicit profile replacement/authorization and independent selection. +public sealed class LocationProviderProfileInput +{ + [Required, RegularExpression("geoapify|mapbox")] public string ProviderKey { get; set; } = string.Empty; + [DataType(DataType.Password), StringLength(2048)] public string? ReplacementCredential { get; set; } + public bool GeocodingAuthorized { get; set; } + public bool RoutingAuthorized { get; set; } + public bool ActiveForGeocoding { get; set; } + public bool ActiveForRouting { get; set; } +} + +/// Accepts one bounded provider-native guard setting. +public sealed class LocationProviderGuardInput +{ + [Required, RegularExpression("geoapify|mapbox-permanent|mapbox-directions")] public string GuardKey { get; set; } = string.Empty; + public bool Enabled { get; set; } + [Range(0, 10_000_000)] public int Limit { get; set; } +} diff --git a/Areas/User/Views/ApiToken/Index.cshtml b/Areas/User/Views/ApiToken/Index.cshtml index 6aec273d..85d5634a 100644 --- a/Areas/User/Views/ApiToken/Index.cshtml +++ b/Areas/User/Views/ApiToken/Index.cshtml @@ -206,7 +206,7 @@

Token: @(token.IsHashedToken ? token.DisplayToken : token.Token) + class="api-token-value border p-2 rounded bg-body-tertiary user-select-all">@token.DisplayToken @if (token.IsHashedToken) { @@ -295,7 +295,7 @@

Content-Type: application/json

Authorization: - Bearer @(token.IsHashedToken ? "" : token.Token)

+ Bearer <your-token>

Required parameters: latitude, longitude, @@ -314,7 +314,7 @@

  • Add the following headers:
    • Content-Type: application/json
    • -
    • Authorization: Bearer @(token.IsHashedToken ? "" : token.Token)
    • +
    • Authorization: Bearer <your-token>
  • Use the following JSON Template:
  • @@ -360,7 +360,7 @@

    Service: @token.Name

    Created At: @token.CreatedAt

    Token: @(token.IsHashedToken ? token.DisplayToken : token.Token)

    + class="api-token-value border p-2 rounded bg-body-tertiary user-select-all">@token.DisplayToken

    @@ -623,7 +623,7 @@ @:var newTokenFromTempData = "@Html.Raw(newToken ?? "")"; @:var newTokenNameFromTempData = "@Html.Raw(newTokenName ?? "")"; @:var isHashedToken = @(token.IsHashedToken ? "true" : "false"); - @:var plainToken = "@Html.Raw(token.Token ?? "")"; + @:var plainToken = ""; @:var serverBaseUrl = "@serverBaseUrl"; @:var userName = "@Model.UserName"; @:var serverName = "@Context.Request.Host.Host"; diff --git a/Areas/User/Views/LocationProviderSettings/Index.cshtml b/Areas/User/Views/LocationProviderSettings/Index.cshtml new file mode 100644 index 00000000..caa73108 --- /dev/null +++ b/Areas/User/Views/LocationProviderSettings/Index.cshtml @@ -0,0 +1,44 @@ +@model Wayfarer.Areas.User.LocationProviderModels.LocationProviderSettingsViewModel + +

    Personal location providers

    +

    Credentials are protected and never displayed again or sent to WayfarerMobile. Read the credential and usage guide.

    +@if (Model.LegacyMigrationState is Wayfarer.Models.LocationProviders.LegacyMapboxMigrationState.Conflict or Wayfarer.Models.LocationProviders.LegacyMapboxMigrationState.ProtectedCredentialUnavailable) +{
    Legacy Mapbox migration needs explicit recovery. No provider contact is authorized and no stored value was removed.
    } +
    Wayfarer records only its own contacts. Other applications can consume the provider account allowance; use a dedicated Wayfarer key when possible. Multiple keys may still share one provider allowance.
    +@foreach (var profile in Model.Profiles) +{ +
    +

    @profile.DisplayName

    +

    Credential: @(profile.CredentialConfigured ? profile.Mask + " (configured)" : "not configured")

    +
    + + +
    Leave blank to retain this profile's credential. Switching never deletes credentials.
    +
    +
    +
    +
    + +
    + @if (profile.ProviderKey == "mapbox") + { +

    Directions guard: @(profile.DirectionsGuardEnabled == true ? "enabled" : "disabled") — @profile.DirectionsUsed / @profile.DirectionsLimit contacts. Separate Wayfarer UTC calendar-month safety cycle.

    +
    + +
    +
    +
    +
    + } +

    Guard: @(profile.GuardEnabled ? "enabled" : "disabled") — @profile.Used / @profile.Limit @profile.Unit. @profile.WindowExplanation. @(profile.Exhausted ? "New contacts are paused; stored data remains available." : "")

    +
    + +
    +
    +
    +
    + @if (profile.CredentialConfigured) + {
    } +
    +} +

    Cache and stored-result reuse costs no new credit. Exhaustion pauses new contacts without deleting source or historical data. Disabling a guard may incur paid usage. Imports and backfills share the same remaining allowance.

    diff --git a/Areas/User/Views/Settings/Index.cshtml b/Areas/User/Views/Settings/Index.cshtml index 8594931b..56a45fed 100644 --- a/Areas/User/Views/Settings/Index.cshtml +++ b/Areas/User/Views/Settings/Index.cshtml @@ -131,6 +131,7 @@

    Routing Settings

    diff --git a/Models/ApiToken.cs b/Models/ApiToken.cs index 7168eacc..4ce2c991 100644 --- a/Models/ApiToken.cs +++ b/Models/ApiToken.cs @@ -40,9 +40,8 @@ public class ApiToken public bool IsHashedToken => TokenHash != null; /// - /// Gets a display-safe representation of the token. - /// Returns masked value for hashed tokens, actual value for third-party tokens. + /// Gets a fixed display-safe representation without redisplaying stored provider credentials. /// - public string DisplayToken => IsHashedToken ? "••••••••••••••••" : (Token ?? ""); + public string DisplayToken => "••••••••••••••••"; } } diff --git a/Models/ApplicationDbContext.cs b/Models/ApplicationDbContext.cs index eccf96f9..76db598c 100644 --- a/Models/ApplicationDbContext.cs +++ b/Models/ApplicationDbContext.cs @@ -83,9 +83,12 @@ protected override void OnModelCreating(ModelBuilder builder) .IsUnique() .HasDatabaseName("IX_Location_UserId_IdempotencyKey"); - builder.Entity() - .Property(at => at.UserId) - .IsRequired(); + builder.Entity() + .Property(at => at.UserId) + .IsRequired(); + + // Legacy Mapbox plaintext is migration recovery state, never a generic token/contact source. + builder.Entity().HasQueryFilter(at => at.Name.Trim().ToLower() != "mapbox"); builder.Entity() .Property(at => at.CreatedAt) diff --git a/Services/LocationProviders/LegacyMapboxMigrationService.cs b/Services/LocationProviders/LegacyMapboxMigrationService.cs index 655d4f0c..ad8d61a3 100644 --- a/Services/LocationProviders/LegacyMapboxMigrationService.cs +++ b/Services/LocationProviders/LegacyMapboxMigrationService.cs @@ -93,7 +93,7 @@ private async Task> LockLegacyRowsAsync(string userId, Cancellati SELECT * FROM "ApiTokens" WHERE "UserId" = {{userId}} AND lower(btrim("Name")) = 'mapbox' AND btrim(COALESCE("Token", '')) <> '' FOR UPDATE """).ToListAsync(cancellationToken) - : await dbContext.ApiTokens.Where(item => item.UserId == userId && item.Token != null) + : await dbContext.ApiTokens.IgnoreQueryFilters().Where(item => item.UserId == userId && item.Token != null) .ToListAsync(cancellationToken); return rows.Where(item => PersonalProviderKeys.IsLegacyMapbox(item.Name) && !string.IsNullOrWhiteSpace(item.Token)).ToList(); diff --git a/Util/ApiTokenService.cs b/Util/ApiTokenService.cs index af694030..19fbf4b2 100644 --- a/Util/ApiTokenService.cs +++ b/Util/ApiTokenService.cs @@ -158,9 +158,11 @@ public async Task ValidateApiTokenAsync(string userId, string token) public async Task> GetTokensForUserAsync(string userId) { List tokens = await _dbContext.ApiTokens + .AsNoTracking() .Where(t => t.UserId == userId) .ToListAsync(); - + // Token-management presentation never receives stored third-party plaintext. + foreach (var token in tokens) token.Token = null; return tokens; } @@ -203,4 +205,4 @@ private static string ToCustomUrlSafeBase64(byte[] tokenData) return $"wf_{base64}"; } } -} \ No newline at end of file +} diff --git a/tests/Wayfarer.Tests/Services/PersonalLocationProviderFoundationTests.cs b/tests/Wayfarer.Tests/Services/PersonalLocationProviderFoundationTests.cs index f16c5a47..848d1ce4 100644 --- a/tests/Wayfarer.Tests/Services/PersonalLocationProviderFoundationTests.cs +++ b/tests/Wayfarer.Tests/Services/PersonalLocationProviderFoundationTests.cs @@ -99,7 +99,7 @@ public async Task LegacyMigration_ProtectsBeforeRetiringAndPreservesUnrelatedTok Assert.Equal("legacy-key", owner.Read(profile).Credential); Assert.True(profile.GeocodingAuthorized); Assert.False(profile.RoutingAuthorized); - Assert.DoesNotContain(await db.ApiTokens.ToListAsync(), item => PersonalProviderKeys.IsLegacyMapbox(item.Name)); + Assert.DoesNotContain(await db.ApiTokens.IgnoreQueryFilters().ToListAsync(), item => PersonalProviderKeys.IsLegacyMapbox(item.Name)); Assert.Contains(await db.ApiTokens.ToListAsync(), item => item.Name == "mobile"); } @@ -118,7 +118,30 @@ public async Task LegacyMigration_PreservesDistinctRecognizedValuesAndFailsClose new PersonalProviderCredentialService(new EphemeralDataProtectionProvider())).MigrateAsync(user.Id); Assert.Equal(LegacyMapboxMigrationState.Conflict, result.State); - Assert.Equal(2, await db.ApiTokens.CountAsync()); + Assert.Equal(2, await db.ApiTokens.IgnoreQueryFilters().CountAsync()); Assert.Null((await db.PersonalLocationProviderProfiles.SingleAsync()).ProtectedCredential); } + + [Fact] + public void PersistentKeyRing_ReadsCredentialAfterServiceRecreation() + { + var path = Path.Combine(Path.GetTempPath(), $"wayfarer-provider-keys-{Guid.NewGuid():N}"); + Directory.CreateDirectory(path); + try + { + var profile = PersonalLocationProviderProfile.Create("restart-user", PersonalLocationProvider.Geoapify); + var first = new PersonalProviderCredentialService(DataProtectionProvider.Create( + new DirectoryInfo(path), options => options.SetApplicationName("Wayfarer"))); + first.Replace(profile, "restart-safe-key"); + + var recreated = new PersonalProviderCredentialService(DataProtectionProvider.Create( + new DirectoryInfo(path), options => options.SetApplicationName("Wayfarer"))); + + Assert.Equal("restart-safe-key", recreated.Read(profile).Credential); + } + finally + { + Directory.Delete(path, recursive: true); + } + } } From ce53828e54c554cc35ae4e978e0d05dba4e17b8b Mon Sep 17 00:00:00 2001 From: Stef Kariotidis Date: Sun, 23 Aug 2026 12:28:48 +0300 Subject: [PATCH 06/13] docs(providers): document credentials usage and key durability --- README.md | 4 +-- appsettings.Production.json | 3 +++ deployment/deploy.sh | 3 +++ deployment/install.sh | 3 +++ deployment/wayfarer.service | 1 + docs/03-Features.md | 2 +- docs/07-Importing-Exporting.md | 3 +-- docs/08-Mobile.md | 1 + docs/09-Troubleshooting.md | 2 ++ docs/15-Architecture.md | 2 +- docs/16-Configuration.md | 3 ++- docs/17-Services.md | 4 +-- docs/19-Database.md | 1 + docs/20-Deployment.md | 2 ++ docs/21-Security.md | 2 +- docs/24-Personal-Location-Providers.md | 35 ++++++++++++++++++++++++++ docs/_sidebar.md | 3 ++- 17 files changed, 63 insertions(+), 11 deletions(-) create mode 100644 docs/24-Personal-Location-Providers.md diff --git a/README.md b/README.md index 97919a95..45eaa4be 100644 --- a/README.md +++ b/README.md @@ -31,7 +31,7 @@ If you expose Wayfarer publicly, you are responsible for: * **Import deduplication** prevents duplicate entries automatically. * **Metadata preservation** — accuracy, speed, altitude, heading tracked per location. * **Export locations** to GeoJSON, KML, CSV, or GPX formats with full metadata. -* **Reverse geocoding** enriches coordinates with addresses (Mapbox token required). +* **Personal location providers** use protected per-user Geoapify or Mapbox profiles with independent geocoding/routing authorization and durable provider-native guards; see the [personal provider guide](docs/24-Personal-Location-Providers.md). * **Wikipedia integration** — discover related articles for any location or trip place. * **Location statistics** — visit counts by country, region, and city. * **Bulk edit notes** to update multiple records at once. @@ -134,7 +134,7 @@ manifest. 2. Configure thresholds, cache limits, and registration mode under **Admin > Settings**. 3. Invite users or enable open registration; managers only see data from users who trust them. -4. (Optional) Add a personal Mapbox token on your account to enrich locations with addresses. +4. (Optional) Configure a protected personal location-provider profile; see the [credential, switching, privacy, and usage guide](docs/24-Personal-Location-Providers.md). > **Note:** The `appsettings.json` files contain placeholder database passwords. For production, configure credentials via systemd environment variables—see the [Deployment Guide](https://stef-k.github.io/Wayfarer/#/developer/26-Deployment). diff --git a/appsettings.Production.json b/appsettings.Production.json index 184a7643..48c802bc 100644 --- a/appsettings.Production.json +++ b/appsettings.Production.json @@ -15,6 +15,9 @@ "Application": { "ContactEmail": "admin@your-domain.example" }, + "DataProtection": { + "KeyRingPath": "/var/lib/wayfarer/data-protection-keys" + }, "CacheSettings": { "TileCacheDirectory": "/var/www/wayfarer/TileCache", "ImageCacheDirectory": "/var/www/wayfarer/ImageCache", diff --git a/deployment/deploy.sh b/deployment/deploy.sh index 1afd7117..0d88904b 100644 --- a/deployment/deploy.sh +++ b/deployment/deploy.sh @@ -226,6 +226,9 @@ sudo chown -R "$APP_USER":"$APP_USER" "$DEPLOY_DIR" # Ensure writable directories exist and have correct permissions echo "Ensuring writable directories exist..." sudo mkdir -p "$DEPLOY_DIR/Uploads" "$DEPLOY_DIR/TileCache" "$DEPLOY_DIR/ImageCache" "$DEPLOY_DIR/ChromeCache" "$DEPLOY_DIR/Logs" +sudo mkdir -p /var/lib/wayfarer/data-protection-keys +sudo chown -R "$APP_USER":"$APP_USER" /var/lib/wayfarer +sudo chmod 700 /var/lib/wayfarer /var/lib/wayfarer/data-protection-keys sudo chown -R "$APP_USER":"$APP_USER" "$DEPLOY_DIR/Uploads" "$DEPLOY_DIR/TileCache" "$DEPLOY_DIR/ImageCache" "$DEPLOY_DIR/ChromeCache" "$DEPLOY_DIR/Logs" sudo chmod 755 "$DEPLOY_DIR/Uploads" "$DEPLOY_DIR/TileCache" "$DEPLOY_DIR/ImageCache" "$DEPLOY_DIR/ChromeCache" "$DEPLOY_DIR/Logs" diff --git a/deployment/install.sh b/deployment/install.sh index 4d626392..6b24751d 100644 --- a/deployment/install.sh +++ b/deployment/install.sh @@ -441,6 +441,9 @@ echo "" echo "Creating deployment directory (if needed) and setting ownership." sudo mkdir -p "$DEPLOY_DIR" sudo chown -R "$APP_USER":"$APP_USER" "$DEPLOY_DIR" +sudo mkdir -p /var/lib/wayfarer/data-protection-keys +sudo chown -R "$APP_USER":"$APP_USER" /var/lib/wayfarer +sudo chmod 700 /var/lib/wayfarer /var/lib/wayfarer/data-protection-keys # ------------------------------ # 6. Configure PostgreSQL diff --git a/deployment/wayfarer.service b/deployment/wayfarer.service index 9995f8ec..155ec294 100644 --- a/deployment/wayfarer.service +++ b/deployment/wayfarer.service @@ -56,6 +56,7 @@ Environment=DOTNET_ENVIRONMENT=Production Environment=DOTNET_PRINT_TELEMETRY_MESSAGE=false # HOME is required for Playwright's Chromium runtime profiles (PDF export) Environment=HOME=/home/wayfarer +Environment=DataProtection__KeyRingPath=/var/lib/wayfarer/data-protection-keys # TILE PROXY CONTACT EMAIL # Used in the User-Agent header sent to tile providers (e.g. OpenStreetMap). diff --git a/docs/03-Features.md b/docs/03-Features.md index 61a35124..0144e4fe 100644 --- a/docs/03-Features.md +++ b/docs/03-Features.md @@ -29,7 +29,7 @@ Wayfarer is a comprehensive self-hosted travel companion with location tracking, - **Import deduplication** prevents duplicate entries based on timestamp and coordinates. - **Metadata preservation** — accuracy, speed, altitude, heading, and source tracked per location. - **Export locations** to GeoJSON, KML, CSV, or GPX formats with full metadata. -- **Reverse geocoding** enriches coordinates with addresses when a Mapbox token is configured. +- **Personal location providers** retain protected Geoapify/Mapbox profiles with independent capability selection and provider-native usage guards; see [Personal Location Providers](24-Personal-Location-Providers.md). - **Wikipedia integration** — click the Wiki button on any location to see nearby Wikipedia articles; uses dual geo + text search for reliable discovery. - **Activity types** categorize entries (walking, driving, eating, etc.). - **Inline activity editing** — edit activity type directly from location modals and tables. diff --git a/docs/07-Importing-Exporting.md b/docs/07-Importing-Exporting.md index c0164697..f8099b84 100644 --- a/docs/07-Importing-Exporting.md +++ b/docs/07-Importing-Exporting.md @@ -46,8 +46,7 @@ CSV uses the CSV importer and suits spreadsheets/Python. GeoJSON uses the Wayfar ### Reverse Geocoding (Optional) -- Add a personal Mapbox API token to enrich imported points with addresses. -- Create an API token named "Mapbox" under your account, then re-run imports to enrich future data. +- Configure an authorized and verified personal provider profile before address enrichment. Imports share its remaining guard allowance and preserve retryable source data on exhaustion; see [Personal Location Providers](24-Personal-Location-Providers.md). - Without a token, imports still work; address fields stay blank. ### Metadata Fields diff --git a/docs/08-Mobile.md b/docs/08-Mobile.md index 21a30000..71748935 100644 --- a/docs/08-Mobile.md +++ b/docs/08-Mobile.md @@ -84,6 +84,7 @@ This is not cross-platform waypoint parity. Semantic Via identity, offline waypo - Default: OpenStreetMap tiles via your Wayfarer server. - Configurable tile server URL. - Respect usage policies of tile providers. +- WayfarerMobile never receives personal provider credentials; server-side provider-neutral results and the boundary are documented in [Personal Location Providers](24-Personal-Location-Providers.md). --- diff --git a/docs/09-Troubleshooting.md b/docs/09-Troubleshooting.md index ab2fb67e..55e252fa 100644 --- a/docs/09-Troubleshooting.md +++ b/docs/09-Troubleshooting.md @@ -1,5 +1,7 @@ # Troubleshooting +For unreadable protected credentials, legacy Mapbox conflicts, exhaustion, guard recovery, and key-ring restore, see [Personal Location Providers](24-Personal-Location-Providers.md). + Sign‑In Issues - Wrong password: reset via account page or ask an admin. - Locked out: your admin can unlock accounts. Enable 2FA for extra security. diff --git a/docs/15-Architecture.md b/docs/15-Architecture.md index ceff68aa..37739a7a 100644 --- a/docs/15-Architecture.md +++ b/docs/15-Architecture.md @@ -22,7 +22,7 @@ Overview of Wayfarer's technical architecture, design patterns, and application | Map Icons | [wayfarer-map-icons](https://github.com/stef-k/wayfarer-map-icons) | | Real-time | Server-Sent Events (SSE) | | PDF Export | Microsoft Playwright | -| Geocoding | Mapbox API (optional) | +| Geocoding/routing authority | Protected personal provider profiles and durable provider-native admission; [details](24-Personal-Location-Providers.md) | | Auth | ASP.NET Core Identity with 2FA | | Testing | xUnit | diff --git a/docs/16-Configuration.md b/docs/16-Configuration.md index fba8241d..9df025b2 100644 --- a/docs/16-Configuration.md +++ b/docs/16-Configuration.md @@ -81,7 +81,8 @@ Uploads - Upload staging directory defaults under `Uploads/Temp/` (path visible in Admin Settings). Ensure writable by the app. Reverse Geocoding (Per‑User) -- Users can store a personal Mapbox API token as an `ApiToken` named "Mapbox"; when present, imports and manual adds enrich addresses. +- `DataProtection:KeyRingPath` is the persistent key authority for Identity and protected administrator/personal provider credentials. The supported systemd deployment uses `/var/lib/wayfarer/data-protection-keys`; backup and migration requirements are in [Personal Location Providers](24-Personal-Location-Providers.md). +- `LocationProviders:Geoapify:RollingCreditLimit` defaults to 2,500 credits. `LocationProviders:Mapbox:PermanentGeocodingLimit` and `LocationProviders:Mapbox:DirectionsLimit` configure separate Wayfarer safety counters. Users manage explicit authorization and selection in Personal location providers. Mobile - `MobileGroups:Query:DefaultPageSize` and `MaxPageSize` — paging for mobile group queries. diff --git a/docs/17-Services.md b/docs/17-Services.md index 0910e6b3..a293565e 100644 --- a/docs/17-Services.md +++ b/docs/17-Services.md @@ -31,7 +31,7 @@ This document covers the key services, file parsers, and background jobs in the ### ReverseGeocodingService - Enriches coordinates with address data via Mapbox API. -- Per-user Mapbox token stored as `ApiToken` with name "Mapbox". +- Personal credentials and provider-native admission are owned by the protected provider foundation; legacy `ApiToken` Mapbox rows migrate non-destructively. See [Personal Location Providers](24-Personal-Location-Providers.md). - Populates street, city, country, postal code fields. - **Key File**: `Services/ReverseGeocodingService.cs` @@ -395,7 +395,7 @@ Each provider has a **Minimum interval (seconds)** setting. It defaults to `1.0` Pacing and request budgets are process-local. A committed Admin interval change immediately updates queued pacing state only in the current process; another replica receives no notification. Multi-replica operators must divide every provider limit between replicas or enforce shared pacing and rate limits at an upstream gateway. Wayfarer provides no bundled/demo provider and makes no availability guarantee for manually configured services; administrators are responsible for the service's usage, disclosure, attribution, profile, and availability terms. -Routing credentials are encrypted with ASP.NET Core Data Protection using a routing-specific purpose. Deployments that configure credentials must preserve and share the Data Protection key ring across restarts and application replicas. Losing or rotating away all applicable keys makes saved routing credentials unusable; Wayfarer returns a bounded Admin error and never falls back to plaintext storage or disabled TLS validation. Back up and protect the key ring according to the deployment's existing Data Protection policy. +Routing credentials are encrypted with ASP.NET Core Data Protection using a routing-specific purpose. The supported single-host key-ring authority, backup boundary, and fail-closed startup validation are documented in [Personal Location Providers](24-Personal-Location-Providers.md). Wayfarer does not currently claim multi-replica key sharing. Administrators may separately expose a verified provider configuration as a personal routing template. `Disabled` keeps it server-only, `CredentialRequired` requires each selecting user to store and verify an independently protected credential, and `CredentialFree` stores no user credential. Users explicitly choose either the server default or one approved template; an unavailable personal selection never falls back to the server default or reads its global credential. Personal credentials are masked, bound cryptographically to credential type, user, and provider, and depend on the same persisted Data Protection key ring described above. diff --git a/docs/19-Database.md b/docs/19-Database.md index 2419386a..6d88269b 100644 --- a/docs/19-Database.md +++ b/docs/19-Database.md @@ -6,6 +6,7 @@ Locations are unique by authenticated `(UserId, IdempotencyKey)`, so the same GU ORM & Provider - EF Core with Npgsql provider and NetTopologySuite for spatial types. +- Personal provider profiles, independent selections, Geoapify rolling admissions, and separate Mapbox product meters use constrained PostgreSQL authority; schema and retention are described in [Personal Location Providers](24-Personal-Location-Providers.md). - PostGIS is required (e.g., `geography(Point, 4326)` for `Location.Coordinates`). Key Entities (selected) diff --git a/docs/20-Deployment.md b/docs/20-Deployment.md index fc721981..fd6e64ff 100644 --- a/docs/20-Deployment.md +++ b/docs/20-Deployment.md @@ -405,6 +405,8 @@ sudo systemctl start wayfarer ## Updating Wayfarer +Before the first release that uses protected personal provider profiles, preserve the service user's existing Data Protection keys and configure `/var/lib/wayfarer/data-protection-keys` as described in [Personal Location Providers](24-Personal-Location-Providers.md). Database-only backups are incomplete once protected credentials exist. + ### Automated (Recommended) ```bash diff --git a/docs/21-Security.md b/docs/21-Security.md index e087c232..0f4637e0 100644 --- a/docs/21-Security.md +++ b/docs/21-Security.md @@ -18,7 +18,7 @@ Account Lockout API Tokens - **Wayfarer API tokens** (used for mobile app and API authentication) are stored as SHA-256 hashes—never in plain text. If the database is compromised, the tokens cannot be recovered or reused. - Tokens are shown **only once** when created or regenerated. Users must copy and store them securely. -- **Third-party tokens** (e.g., Mapbox API keys) are stored as provided since the application needs them for outgoing API calls. Use scoped/restricted keys from providers when possible. +- **Personal provider credentials** are protected with purpose-, provider-, and user-bound Data Protection and are never redisplayed or sent to mobile. Key-ring backup, filesystem protection, privacy disclosure, and legacy migration are documented in [Personal Location Providers](24-Personal-Location-Providers.md). - Rotate API tokens regularly and revoke any that may have been exposed. Authorization diff --git a/docs/24-Personal-Location-Providers.md b/docs/24-Personal-Location-Providers.md new file mode 100644 index 00000000..00db3e81 --- /dev/null +++ b/docs/24-Personal-Location-Providers.md @@ -0,0 +1,35 @@ +# Personal Location Providers + +Wayfarer stores one personal credential per user and provider (`Geoapify` or `Mapbox`). Credentials are protected with ASP.NET Core Data Protection and cryptographically bound to credential type, provider, and user. Browser and mobile responses show only a fixed mask; WayfarerMobile never receives provider credentials. + +## Key-ring durability and backup + +The supported Linux/systemd deployment sets `DataProtection__KeyRingPath=/var/lib/wayfarer/data-protection-keys`. The installer/deployer creates that directory as the `wayfarer` service identity with mode `0700`. It survives process restarts and `/var/www/wayfarer` publish replacement. Keys are scoped to the application name `Wayfarer`; at-rest protection is the dedicated service identity plus host filesystem permissions and disk/host encryption. Wayfarer does not claim certificate, cloud-KMS, container, or multi-host key sharing. + +Back up the key-ring directory together with the PostgreSQL database and restore both from the same recovery set. Losing applicable keys makes protected credentials unreadable. Startup fails closed if the directory is unusable or any retained administrator/personal routing or location-provider credential cannot be read. Before changing an existing deployment to the explicit path, stop Wayfarer and copy the existing service-user ring from `/home/wayfarer/.aspnet/DataProtection-Keys` if it exists; retain the original backup until startup and credential readback succeed. + +## Profiles, authorization, and switching + +Geocoding and routing authorization, verification, and active selection are independent. “No provider” is supported. A replacement advances the credential generation and invalidates both verifications without changing authorization or usage. Revocation removes ciphertext, disables both capabilities, and preserves usage and all Locations, Timeline records, Places, Trips, Segments, addresses, enrichment, geometry, and accepted routes. Switching changes selection only: inactive profiles, credentials, verification history, guards, and usage remain retained. + +Provider contact requires the active selection, an authorized and currently verified capability, readable current-generation credential, and usage admission. Replacement, revocation, disabling, or relevant switching invalidates stale in-flight authority before contact or persistence. Provider adapters own HTTP, cost calculation, payload parsing, normalization, retries, and domain persistence. + +## Legacy Mapbox migration + +On the authenticated user’s provider-settings entry and common geocoding resolver, Wayfarer recognizes only trimmed, case-insensitive exact `Mapbox` names. It never performs a startup-wide scan. One unambiguous value is protected, read back through production Data Protection, compared in memory, and only then are exact matching legacy rows retired. Geocoding is authorized; routing is not. + +Valid protected data always wins and is never overwritten. Matching duplicate casing rows converge; distinct values, invalid ciphertext, and revoked profiles preserve every recovery copy and fail closed without provider contact. Reruns are idempotent. Unrelated inbound Wayfarer API tokens and all domain data are untouched. + +## Provider-native usage guards + +Geoapify uses one shared user/profile pool for geocoding and routing. The default guard is enabled at 2,500 credits in a true rolling 24-hour Wayfarer safety window. PostgreSQL time and a locked pool row make multi-credit admission atomic across restarts and application instances; admitted failures count. Expired rows are removed under the same lock. Disabled guards still retain and clean the current rolling window so re-enabling does not reset it. + +Mapbox Permanent Geocoding and Directions have separate counters, limits, exhaustion, and Wayfarer UTC calendar-month safety cycles. This is a configured Wayfarer boundary, not a claim about an unpublished provider reset timezone. Rotation and switching do not reset either product. One product’s exhaustion does not pause the other. + +Wayfarer counts only contacts it admits. Cached/stored reuse and pre-HTTP rejection cost zero; admitted failures, timeouts, and admitted retries remain counted. Other applications or credentials may consume the provider account allowance. A dedicated Wayfarer key is recommended, but multiple keys do not necessarily create separate free allowances. Disabling a guard permits contacts beyond the configured safety limit and may incur paid usage. + +## Exhaustion, imports, privacy, and recovery + +Exhaustion stops new provider contact and recovers automatically as rolling credits expire, a product cycle advances, or a guard is raised/disabled. Source records remain retryable and historical data remains available. Imports and backfills use the same remaining pool and receive no catch-up burst. + +Provider contact discloses coordinates and may disclose route inputs to the selected provider. Query-string authentication may be provider-required, but complete URIs, credentials, coordinates, returned addresses, request/response payloads, and imported content are excluded from Wayfarer logs and diagnostics. Revoke a provider key at both Wayfarer and the provider account when compromise is suspected; revocation does not delete historical data. diff --git a/docs/_sidebar.md b/docs/_sidebar.md index 467e40e7..8b1f659c 100644 --- a/docs/_sidebar.md +++ b/docs/_sidebar.md @@ -21,7 +21,8 @@ - [API](18-API.md) - [Database](19-Database.md) - [Deployment](20-Deployment.md) - - [Security](21-Security.md) +- [Security](21-Security.md) +- [Personal Location Providers](24-Personal-Location-Providers.md) - [Testing](22-Testing.md) - [Versioning](23-Versioning.md) From 8effa3fed98ff698af6131ced14ab2d2908084f7 Mon Sep 17 00:00:00 2001 From: Stef Kariotidis Date: Sun, 23 Aug 2026 12:36:03 +0300 Subject: [PATCH 07/13] fix(providers): enforce authority integrity and complete regression coverage --- ...onalProviderSelectionIntegrity.Designer.cs | 2330 +++++++++++++++++ ...forcePersonalProviderSelectionIntegrity.cs | 69 + .../ApplicationDbContextModelSnapshot.cs | 17 + .../ApplicationDbContext.LocationProviders.cs | 19 + Models/ApplicationDbContext.cs | 15 +- .../PersonalLocationProviderConfiguration.cs | 14 + .../LegacyMapboxMigration.cs | 1 + .../LegacyMapboxMigrationService.cs | 9 +- .../PersonalProviderContactGate.cs | 22 +- .../PersonalProviderCredentialService.cs | 21 + .../TripEditorPlaceControllerTests.cs | 12 +- .../UserApiTokenControllerTests.cs | 9 +- .../PersonalProviderUsagePostgresTests.cs | 62 + .../Services/ApiTokenServiceTests.cs | 4 +- .../MobileCurrentUserAccessorTests.cs | 6 +- ...PersonalLocationProviderFoundationTests.cs | 88 + 16 files changed, 2666 insertions(+), 32 deletions(-) create mode 100644 Migrations/20260823092929_EnforcePersonalProviderSelectionIntegrity.Designer.cs create mode 100644 Migrations/20260823092929_EnforcePersonalProviderSelectionIntegrity.cs create mode 100644 Models/ApplicationDbContext.LocationProviders.cs diff --git a/Migrations/20260823092929_EnforcePersonalProviderSelectionIntegrity.Designer.cs b/Migrations/20260823092929_EnforcePersonalProviderSelectionIntegrity.Designer.cs new file mode 100644 index 00000000..bebb5d82 --- /dev/null +++ b/Migrations/20260823092929_EnforcePersonalProviderSelectionIntegrity.Designer.cs @@ -0,0 +1,2330 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using NetTopologySuite.Geometries; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using Wayfarer.Models; + +#nullable disable + +namespace Wayfarer.Migrations +{ + [DbContext(typeof(ApplicationDbContext))] + [Migration("20260823092929_EnforcePersonalProviderSelectionIntegrity")] + partial class EnforcePersonalProviderSelectionIntegrity + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.11") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "citext"); + NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "postgis"); + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("ApplicationSettings", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ActiveRoutingProviderConfigurationId") + .HasColumnType("uuid"); + + b.Property("ExternalRouteGenerationEnabled") + .HasColumnType("boolean"); + + b.Property("ExternalRouteGenerationVersion") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(1); + + b.Property("ImageCacheExpiryDays") + .HasColumnType("integer"); + + b.Property("IsRegistrationOpen") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("LocationAccuracyThresholdMeters") + .HasColumnType("integer"); + + b.Property("LocationDistanceThresholdMeters") + .HasColumnType("integer"); + + b.Property("LocationTimeThresholdMinutes") + .HasColumnType("integer"); + + b.Property("MaxCacheImageSizeInMB") + .HasColumnType("integer"); + + b.Property("MaxCacheTileSizeInMB") + .HasColumnType("integer"); + + b.Property("MaxProxyImageDownloadMB") + .HasColumnType("integer"); + + b.Property("ProxyImageRateLimitEnabled") + .HasColumnType("boolean"); + + b.Property("ProxyImageRateLimitPerMinute") + .HasColumnType("integer"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.Property("TileMetadataHotCacheSizeMB") + .HasColumnType("integer"); + + b.Property("TileOutboundBudgetHistorical30Acknowledged") + .HasColumnType("boolean"); + + b.Property("TileOutboundBudgetPerIpPerMinute") + .HasColumnType("integer"); + + b.Property("TileProviderAdvancedLimitsEnabled") + .HasColumnType("boolean"); + + b.Property("TileProviderApiKey") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("TileProviderAttribution") + .IsRequired() + .HasMaxLength(300) + .HasColumnType("character varying(300)"); + + b.Property("TileProviderBurstCapacity") + .HasColumnType("integer"); + + b.Property("TileProviderFallbackBaseDelayMs") + .HasColumnType("integer"); + + b.Property("TileProviderFallbackDelayCapSeconds") + .HasColumnType("integer"); + + b.Property("TileProviderKey") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("TileProviderMaxAttempts") + .HasColumnType("integer"); + + b.Property("TileProviderMaxConcurrency") + .HasColumnType("integer"); + + b.Property("TileProviderMaxIndividualWaitSeconds") + .HasColumnType("integer"); + + b.Property("TileProviderSustainedRequestsPerSecond") + .HasColumnType("integer"); + + b.Property("TileProviderTotalRetryCeilingSeconds") + .HasColumnType("integer"); + + b.Property("TileProviderUrlTemplate") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("TileRateLimitAuthenticatedPerMinute") + .HasColumnType("integer"); + + b.Property("TileRateLimitEnabled") + .HasColumnType("boolean"); + + b.Property("TileRateLimitPerMinute") + .HasColumnType("integer"); + + b.Property("TileTrafficMode") + .HasColumnType("integer"); + + b.Property("UploadSizeLimitMB") + .HasColumnType("integer"); + + b.Property("VisitNotificationCooldownHours") + .HasColumnType("integer"); + + b.Property("VisitedAccuracyMultiplier") + .HasColumnType("double precision"); + + b.Property("VisitedAccuracyRejectMeters") + .HasColumnType("integer"); + + b.Property("VisitedMaxRadiusMeters") + .HasColumnType("integer"); + + b.Property("VisitedMaxSearchRadiusMeters") + .HasColumnType("integer"); + + b.Property("VisitedMinRadiusMeters") + .HasColumnType("integer"); + + b.Property("VisitedPlaceNotesSnapshotMaxHtmlChars") + .HasColumnType("integer"); + + b.Property("VisitedRequiredHits") + .HasColumnType("integer"); + + b.Property("VisitedSuggestionMaxRadiusMultiplier") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ActiveRoutingProviderConfigurationId"); + + b.ToTable("ApplicationSettings"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("RoleId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("ProviderKey") + .HasColumnType("text"); + + b.Property("ProviderDisplayName") + .HasColumnType("text"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("text"); + + b.Property("RoleId") + .HasColumnType("text"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("text"); + + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("Name") + .HasColumnType("text"); + + b.Property("Value") + .HasColumnType("text"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("TripTags", b => + { + b.Property("TripId") + .HasColumnType("uuid"); + + b.Property("TagId") + .HasColumnType("uuid"); + + b.HasKey("TripId", "TagId"); + + b.HasIndex("TagId"); + + b.HasIndex("TripId"); + + b.ToTable("TripTags", (string)null); + }); + + modelBuilder.Entity("Wayfarer.Models.ActivityType", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("ActivityTypes"); + }); + + modelBuilder.Entity("Wayfarer.Models.ApiToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("Token") + .HasColumnType("text"); + + b.Property("TokenHash") + .HasColumnType("text"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.HasIndex("Name", "UserId") + .IsUnique() + .HasDatabaseName("IX_ApiToken_Name_UserId"); + + b.ToTable("ApiTokens"); + }); + + modelBuilder.Entity("Wayfarer.Models.ApplicationUser", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("AccessFailedCount") + .HasColumnType("integer"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("DisplayName") + .IsRequired() + .HasColumnType("text"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("boolean"); + + b.Property("IsActive") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true); + + b.Property("IsProtected") + .HasColumnType("boolean"); + + b.Property("IsTimelinePublic") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("LockoutEnabled") + .HasColumnType("boolean"); + + b.Property("LockoutEnd") + .HasColumnType("timestamp with time zone"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("PasswordHash") + .HasColumnType("text"); + + b.Property("PhoneNumber") + .HasColumnType("text"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("boolean"); + + b.Property("PublicTimelineTimeThreshold") + .HasColumnType("text"); + + b.Property("SecurityStamp") + .HasColumnType("text"); + + b.Property("TimelineTitle") + .HasMaxLength(80) + .HasColumnType("character varying(80)"); + + b.Property("TwoFactorEnabled") + .HasColumnType("boolean"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.HasIndex("UserName") + .IsUnique(); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("Wayfarer.Models.Area", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("DisplayOrder") + .HasColumnType("integer"); + + b.Property("FillHex") + .HasColumnType("text"); + + b.Property("Geometry") + .IsRequired() + .HasColumnType("geometry"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Notes") + .HasColumnType("text"); + + b.Property("RegionId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("RegionId"); + + b.ToTable("Areas"); + }); + + modelBuilder.Entity("Wayfarer.Models.AuditLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Action") + .IsRequired() + .HasColumnType("text"); + + b.Property("Details") + .IsRequired() + .HasColumnType("text"); + + b.Property("Timestamp") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("AuditLogs"); + }); + + modelBuilder.Entity("Wayfarer.Models.Group", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("Description") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("GroupType") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("IsArchived") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("OrgPeerVisibilityEnabled") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("OwnerUserId") + .IsRequired() + .HasColumnType("text"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.HasKey("Id"); + + b.HasIndex("OwnerUserId", "Name") + .IsUnique(); + + b.ToTable("Groups"); + }); + + modelBuilder.Entity("Wayfarer.Models.GroupInvitation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("GroupId") + .HasColumnType("uuid"); + + b.Property("InviteeEmail") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("InviteeUserId") + .HasColumnType("text"); + + b.Property("InviterUserId") + .IsRequired() + .HasColumnType("text"); + + b.Property("RespondedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("Token") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("InviteeUserId"); + + b.HasIndex("InviterUserId"); + + b.HasIndex("Token") + .IsUnique(); + + b.HasIndex("GroupId", "InviteeUserId") + .IsUnique() + .HasDatabaseName("IX_GroupInvitation_GroupId_InviteeUserId_Pending") + .HasFilter("\"Status\" = 'Pending' AND \"InviteeUserId\" IS NOT NULL"); + + b.ToTable("GroupInvitations"); + }); + + modelBuilder.Entity("Wayfarer.Models.GroupMember", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("GroupId") + .HasColumnType("uuid"); + + b.Property("JoinedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("LeftAt") + .HasColumnType("timestamp with time zone"); + + b.Property("OrgPeerVisibilityAccessDisabled") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("Role") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.HasIndex("GroupId", "Status") + .HasDatabaseName("IX_GroupMember_GroupId_Status"); + + b.HasIndex("GroupId", "UserId") + .IsUnique(); + + b.ToTable("GroupMembers"); + }); + + modelBuilder.Entity("Wayfarer.Models.HiddenArea", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Area") + .IsRequired() + .HasColumnType("geometry"); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("HiddenAreas"); + }); + + modelBuilder.Entity("Wayfarer.Models.ImageCacheMetadata", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CacheKey") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("ContentType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("FilePath") + .IsRequired() + .HasColumnType("text"); + + b.Property("LastAccessed") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.Property("Size") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("CacheKey") + .IsUnique() + .HasDatabaseName("IX_ImageCacheMetadata_CacheKey"); + + b.ToTable("ImageCacheMetadata"); + }); + + modelBuilder.Entity("Wayfarer.Models.JobHistory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("JobName") + .IsRequired() + .HasColumnType("text"); + + b.Property("LastRunTime") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("JobHistories"); + }); + + modelBuilder.Entity("Wayfarer.Models.Location", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Accuracy") + .HasColumnType("double precision"); + + b.Property("ActivityTypeId") + .HasColumnType("integer"); + + b.Property("Address") + .HasColumnType("text"); + + b.Property("AddressNumber") + .HasColumnType("text"); + + b.Property("Altitude") + .HasColumnType("double precision"); + + b.Property("AppBuild") + .HasColumnType("text"); + + b.Property("AppVersion") + .HasColumnType("text"); + + b.Property("BatteryLevel") + .HasColumnType("integer"); + + b.Property("Bearing") + .HasColumnType("double precision"); + + b.Property("Coordinates") + .IsRequired() + .HasColumnType("geography(Point, 4326)"); + + b.Property("Country") + .HasColumnType("text"); + + b.Property("DeviceModel") + .HasColumnType("text"); + + b.Property("FullAddress") + .HasColumnType("text"); + + b.Property("IdempotencyKey") + .HasColumnType("uuid"); + + b.Property("IsCharging") + .HasColumnType("boolean"); + + b.Property("IsUserInvoked") + .HasColumnType("boolean"); + + b.Property("LocalTimestamp") + .HasColumnType("timestamp with time zone"); + + b.Property("LocationType") + .HasColumnType("text"); + + b.Property("Notes") + .HasColumnType("text"); + + b.Property("OsVersion") + .HasColumnType("text"); + + b.Property("Place") + .HasColumnType("text"); + + b.Property("PostCode") + .HasColumnType("text"); + + b.Property("Provider") + .HasColumnType("text"); + + b.Property("Region") + .HasColumnType("text"); + + b.Property("Source") + .HasColumnType("text"); + + b.Property("Speed") + .HasColumnType("double precision"); + + b.Property("StreetName") + .HasColumnType("text"); + + b.Property("TimeZoneId") + .IsRequired() + .HasColumnType("text"); + + b.Property("Timestamp") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("ActivityTypeId"); + + b.HasIndex("Coordinates") + .HasDatabaseName("IX_Location_Coordinates"); + + NpgsqlIndexBuilderExtensions.HasMethod(b.HasIndex("Coordinates"), "GIST"); + + b.HasIndex("UserId", "IdempotencyKey") + .IsUnique() + .HasDatabaseName("IX_Location_UserId_IdempotencyKey"); + + b.ToTable("Locations"); + }); + + modelBuilder.Entity("Wayfarer.Models.LocationImport", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("ErrorMessage") + .HasColumnType("text"); + + b.Property("FilePath") + .IsRequired() + .HasColumnType("text"); + + b.Property("FileType") + .HasColumnType("integer"); + + b.Property("LastImportedRecord") + .HasColumnType("text"); + + b.Property("LastProcessedIndex") + .HasColumnType("integer"); + + b.Property("SkippedDuplicates") + .HasColumnType("integer"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text"); + + b.Property("TotalRecords") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("LocationImports"); + }); + + modelBuilder.Entity("Wayfarer.Models.LocationProviders.GeoapifyUsageAdmission", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AdmittedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("clock_timestamp()"); + + b.Property("Credits") + .HasColumnType("integer"); + + b.Property("Product") + .HasColumnType("integer"); + + b.Property("UserId") + .IsRequired() + .HasMaxLength(450) + .HasColumnType("character varying(450)"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "AdmittedAt"); + + b.ToTable("GeoapifyUsageAdmissions", t => + { + t.HasCheckConstraint("CK_GeoapifyUsageAdmission_Credits", "\"Credits\" > 0"); + + t.HasCheckConstraint("CK_GeoapifyUsageAdmission_Product", "\"Product\" IN (1, 2)"); + }); + }); + + modelBuilder.Entity("Wayfarer.Models.LocationProviders.GeoapifyUsageGuard", b => + { + b.Property("UserId") + .HasMaxLength(450) + .HasColumnType("character varying(450)"); + + b.Property("CreditLimit") + .HasColumnType("integer"); + + b.Property("Enabled") + .HasColumnType("boolean"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.HasKey("UserId"); + + b.ToTable("GeoapifyUsageGuards", t => + { + t.HasCheckConstraint("CK_GeoapifyUsageGuard_Limit", "\"CreditLimit\" >= 0"); + }); + }); + + modelBuilder.Entity("Wayfarer.Models.LocationProviders.MapboxProductMeter", b => + { + b.Property("UserId") + .HasMaxLength(450) + .HasColumnType("character varying(450)"); + + b.Property("Product") + .HasColumnType("integer"); + + b.Property("AdmittedCount") + .HasColumnType("integer"); + + b.Property("CycleStart") + .HasColumnType("date"); + + b.Property("Enabled") + .HasColumnType("boolean"); + + b.Property("Limit") + .HasColumnType("integer"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.HasKey("UserId", "Product"); + + b.ToTable("MapboxProductMeters", t => + { + t.HasCheckConstraint("CK_MapboxProductMeter_Counts", "\"Limit\" >= 0 AND \"AdmittedCount\" >= 0"); + + t.HasCheckConstraint("CK_MapboxProductMeter_Product", "\"Product\" IN (3, 4)"); + }); + }); + + modelBuilder.Entity("Wayfarer.Models.LocationProviders.PersonalLocationProviderProfile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CredentialGeneration") + .HasColumnType("integer"); + + b.Property("GeocodingAuthorized") + .HasColumnType("boolean"); + + b.Property("GeocodingGeneration") + .HasColumnType("integer"); + + b.Property("GeocodingVerification") + .HasColumnType("integer"); + + b.Property("GeocodingVerifiedConfigurationGeneration") + .HasColumnType("integer"); + + b.Property("GeocodingVerifiedCredentialGeneration") + .HasColumnType("integer"); + + b.Property("LegacyMigrationState") + .HasColumnType("integer"); + + b.Property("ProtectedCredential") + .HasMaxLength(4096) + .HasColumnType("character varying(4096)"); + + b.Property("ProviderKey") + .IsRequired() + .HasMaxLength(24) + .HasColumnType("character varying(24)"); + + b.Property("RevokedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("RoutingAuthorized") + .HasColumnType("boolean"); + + b.Property("RoutingGeneration") + .HasColumnType("integer"); + + b.Property("RoutingVerification") + .HasColumnType("integer"); + + b.Property("RoutingVerifiedConfigurationGeneration") + .HasColumnType("integer"); + + b.Property("RoutingVerifiedCredentialGeneration") + .HasColumnType("integer"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .IsRequired() + .HasMaxLength(450) + .HasColumnType("character varying(450)"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "ProviderKey") + .IsUnique(); + + b.ToTable("PersonalLocationProviderProfiles", t => + { + t.HasCheckConstraint("CK_PersonalProvider_Generations", "\"CredentialGeneration\" > 0 AND \"GeocodingGeneration\" > 0 AND \"RoutingGeneration\" > 0"); + + t.HasCheckConstraint("CK_PersonalProvider_Provider", "\"ProviderKey\" IN ('geoapify', 'mapbox')"); + + t.HasCheckConstraint("CK_PersonalProvider_Verification", "\"GeocodingVerification\" BETWEEN 0 AND 3 AND \"RoutingVerification\" BETWEEN 0 AND 3"); + }); + }); + + modelBuilder.Entity("Wayfarer.Models.LocationProviders.PersonalLocationProviderSelection", b => + { + b.Property("UserId") + .HasMaxLength(450) + .HasColumnType("character varying(450)"); + + b.Property("GeocodingProviderKey") + .HasMaxLength(24) + .HasColumnType("character varying(24)"); + + b.Property("GeocodingSelectionGeneration") + .HasColumnType("integer"); + + b.Property("RoutingProviderKey") + .HasMaxLength(24) + .HasColumnType("character varying(24)"); + + b.Property("RoutingSelectionGeneration") + .HasColumnType("integer"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.HasKey("UserId"); + + b.HasIndex("UserId", "GeocodingProviderKey"); + + b.HasIndex("UserId", "RoutingProviderKey"); + + b.ToTable("PersonalLocationProviderSelections", t => + { + t.HasCheckConstraint("CK_PersonalProviderSelection_Geocoding", "\"GeocodingProviderKey\" IS NULL OR \"GeocodingProviderKey\" IN ('geoapify', 'mapbox')"); + + t.HasCheckConstraint("CK_PersonalProviderSelection_Routing", "\"RoutingProviderKey\" IS NULL OR \"RoutingProviderKey\" IN ('geoapify', 'mapbox')"); + }); + }); + + modelBuilder.Entity("Wayfarer.Models.Place", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Address") + .HasColumnType("text"); + + b.Property("DisplayOrder") + .HasColumnType("integer"); + + b.Property("IconName") + .HasColumnType("text"); + + b.Property("Location") + .HasColumnType("geography(Point,4326)"); + + b.Property("MarkerColor") + .HasColumnType("text"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("Notes") + .HasColumnType("text"); + + b.Property("RegionId") + .HasColumnType("uuid"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("RegionId"); + + b.ToTable("Places"); + }); + + modelBuilder.Entity("Wayfarer.Models.PlaceVisitCandidate", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConsecutiveHits") + .HasColumnType("integer"); + + b.Property("FirstHitUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("LastHitUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("PlaceId") + .HasColumnType("uuid"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("LastHitUtc") + .HasDatabaseName("IX_PlaceVisitCandidate_LastHitUtc"); + + b.HasIndex("PlaceId"); + + b.HasIndex("UserId", "PlaceId") + .IsUnique() + .HasDatabaseName("IX_PlaceVisitCandidate_UserId_PlaceId"); + + b.ToTable("PlaceVisitCandidates"); + }); + + modelBuilder.Entity("Wayfarer.Models.PlaceVisitEvent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ArrivedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("EndedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("IconNameSnapshot") + .HasColumnType("text"); + + b.Property("LastSeenAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("MarkerColorSnapshot") + .HasColumnType("text"); + + b.Property("NotesHtml") + .HasColumnType("text"); + + b.Property("PlaceId") + .HasColumnType("uuid"); + + b.Property("PlaceLocationSnapshot") + .HasColumnType("geography(Point,4326)"); + + b.Property("PlaceNameSnapshot") + .IsRequired() + .HasColumnType("text"); + + b.Property("RegionNameSnapshot") + .IsRequired() + .HasColumnType("text"); + + b.Property("Source") + .HasColumnType("text"); + + b.Property("TripIdSnapshot") + .HasColumnType("uuid"); + + b.Property("TripNameSnapshot") + .IsRequired() + .HasColumnType("text"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("ArrivedAtUtc") + .HasDatabaseName("IX_PlaceVisitEvent_ArrivedAtUtc"); + + b.HasIndex("PlaceId") + .HasDatabaseName("IX_PlaceVisitEvent_PlaceId"); + + b.HasIndex("UserId", "EndedAtUtc") + .HasDatabaseName("IX_PlaceVisitEvent_UserId_EndedAtUtc"); + + b.ToTable("PlaceVisitEvents"); + }); + + modelBuilder.Entity("Wayfarer.Models.Region", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Center") + .HasColumnType("geography(Point,4326)"); + + b.Property("CoverImageUrl") + .HasColumnType("text"); + + b.Property("DisplayOrder") + .HasColumnType("integer"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("Notes") + .HasColumnType("text"); + + b.Property("TripId") + .HasColumnType("uuid"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("TripId"); + + b.ToTable("Regions"); + }); + + modelBuilder.Entity("Wayfarer.Models.RoutingProviderConfiguration", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AdapterType") + .HasColumnType("integer"); + + b.Property("Attribution") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("BaseEndpoint") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("ConfigurationVersion") + .HasColumnType("integer"); + + b.Property("CredentialCiphertext") + .HasMaxLength(4096) + .HasColumnType("character varying(4096)"); + + b.Property("CredentialPresent") + .HasColumnType("boolean"); + + b.Property("CredentialRequired") + .HasColumnType("boolean"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("Enabled") + .HasColumnType("boolean"); + + b.Property("ExternalCoordinateDisclosure") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("GenerationTimeoutSeconds") + .HasColumnType("integer"); + + b.Property("MaxConcurrency") + .HasColumnType("integer"); + + b.Property("MinimumIntervalMilliseconds") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(1000); + + b.Property("PersonalRoutingAccess") + .HasColumnType("integer"); + + b.Property("RequestsPerMinute") + .HasColumnType("integer"); + + b.Property("ResponseSizeLimitBytes") + .HasColumnType("integer"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.Property("VerificationFromLatitude") + .HasColumnType("double precision"); + + b.Property("VerificationFromLongitude") + .HasColumnType("double precision"); + + b.Property("VerificationResult") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("VerificationStatus") + .HasMaxLength(80) + .HasColumnType("character varying(80)"); + + b.Property("VerificationToLatitude") + .HasColumnType("double precision"); + + b.Property("VerificationToLongitude") + .HasColumnType("double precision"); + + b.Property("VerifiedConfigurationVersion") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.ToTable("RoutingProviderConfigurations", null, t => + { + t.HasCheckConstraint("CK_RoutingProviderConfigurations_MinimumIntervalMilliseconds", "\"MinimumIntervalMilliseconds\" >= 0 AND \"MinimumIntervalMilliseconds\" <= 60000"); + + t.HasCheckConstraint("CK_RoutingProviderConfigurations_PersonalRoutingAccess", "\"PersonalRoutingAccess\" IN (0, 1, 2)"); + }); + }); + + modelBuilder.Entity("Wayfarer.Models.RoutingProviderProfileMapping", b => + { + b.Property("RoutingProviderConfigurationId") + .HasColumnType("uuid"); + + b.Property("TransportProfileId") + .HasColumnType("uuid"); + + b.Property("OsrmProfile") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("character varying(80)"); + + b.HasKey("RoutingProviderConfigurationId", "TransportProfileId"); + + b.HasIndex("TransportProfileId"); + + b.ToTable("RoutingProviderProfileMappings", (string)null); + }); + + modelBuilder.Entity("Wayfarer.Models.Segment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("DisplayOrder") + .HasColumnType("integer"); + + b.Property("EstimatedDistanceKm") + .HasColumnType("double precision"); + + b.Property("EstimatedDuration") + .HasColumnType("interval"); + + b.Property("EstimatedDurationSource") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0); + + b.Property("FromPlaceId") + .HasColumnType("uuid"); + + b.Property("Mode") + .IsRequired() + .HasColumnType("text"); + + b.Property("Notes") + .HasColumnType("text"); + + b.Property("RouteGeometry") + .HasColumnType("geography(LineString,4326)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.Property("ToPlaceId") + .HasColumnType("uuid"); + + b.Property("TransportProfileId") + .HasColumnType("uuid"); + + b.Property("TripId") + .HasColumnType("uuid"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("FromPlaceId"); + + b.HasIndex("ToPlaceId"); + + b.HasIndex("TransportProfileId"); + + b.HasIndex("TripId"); + + b.ToTable("Segments", t => + { + t.HasCheckConstraint("CK_Segments_EstimatedDurationSource", "\"EstimatedDurationSource\" IN (0, 1)"); + }); + }); + + modelBuilder.Entity("Wayfarer.Models.SegmentWaypoint", b => + { + b.Property("SegmentId") + .HasColumnType("uuid"); + + b.Property("PlaceId") + .HasColumnType("uuid"); + + b.Property("Position") + .HasColumnType("integer"); + + b.Property("RouteVertexIndex") + .HasColumnType("integer"); + + b.HasKey("SegmentId", "PlaceId"); + + b.HasIndex("PlaceId"); + + b.HasIndex("SegmentId", "Position") + .IsUnique() + .HasDatabaseName("IX_SegmentWaypoints_SegmentId_Position"); + + b.HasIndex("SegmentId", "RouteVertexIndex") + .IsUnique() + .HasDatabaseName("IX_SegmentWaypoints_SegmentId_RouteVertexIndex") + .HasFilter("\"RouteVertexIndex\" IS NOT NULL"); + + b.ToTable("SegmentWaypoints", null, t => + { + t.HasCheckConstraint("CK_SegmentWaypoint_Position", "\"Position\" >= 0"); + + t.HasCheckConstraint("CK_SegmentWaypoint_RouteVertexIndex", "\"RouteVertexIndex\" IS NULL OR \"RouteVertexIndex\" > 0"); + }); + }); + + modelBuilder.Entity("Wayfarer.Models.Tag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("citext"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.HasIndex("Slug") + .IsUnique(); + + b.ToTable("Tags"); + }); + + modelBuilder.Entity("Wayfarer.Models.TileCacheMetadata", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ETag") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ExpiresAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("LastAccessed") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("LastModifiedUpstream") + .HasColumnType("timestamp with time zone"); + + b.Property("ProviderIdentity") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.Property("Size") + .HasColumnType("integer"); + + b.Property("TileFilePath") + .HasColumnType("text"); + + b.Property("TileLocation") + .IsRequired() + .HasColumnType("geometry"); + + b.Property("X") + .HasColumnType("integer"); + + b.Property("Y") + .HasColumnType("integer"); + + b.Property("Zoom") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("TileLocation"); + + NpgsqlIndexBuilderExtensions.HasMethod(b.HasIndex("TileLocation"), "GIST"); + + b.HasIndex("Zoom", "X", "Y") + .IsUnique() + .HasFilter("\"ProviderIdentity\" IS NULL"); + + b.HasIndex("ProviderIdentity", "Zoom", "X", "Y") + .IsUnique(); + + b.ToTable("TileCacheMetadata"); + }); + + modelBuilder.Entity("Wayfarer.Models.TransportProfile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Category") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("character varying(80)"); + + b.Property("Description") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("IsSeeded") + .HasColumnType("boolean"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("character varying(80)"); + + b.Property("Label") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("PlanningSpeedKmh") + .HasColumnType("double precision"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("Key") + .IsUnique(); + + b.ToTable("TransportProfiles", null, t => + { + t.HasCheckConstraint("CK_TransportProfile_NormalizedKey", "\"Key\" = lower(trim(\"Key\")) AND length(\"Key\") > 0"); + + t.HasCheckConstraint("CK_TransportProfile_PlanningSpeedKmh", "\"PlanningSpeedKmh\" IS NULL OR (\"PlanningSpeedKmh\" > 0 AND \"PlanningSpeedKmh\" < 1.7976931348623157E+308)"); + }); + }); + + modelBuilder.Entity("Wayfarer.Models.Trip", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CenterLat") + .HasColumnType("double precision"); + + b.Property("CenterLon") + .HasColumnType("double precision"); + + b.Property("CoverImageUrl") + .HasColumnType("text"); + + b.Property("IsPublic") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Notes") + .HasColumnType("text"); + + b.Property("ShareProgressEnabled") + .HasColumnType("boolean"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text"); + + b.Property("Zoom") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("Trips"); + }); + + modelBuilder.Entity("Wayfarer.Models.UserRoutingConfiguration", b => + { + b.Property("UserId") + .HasColumnType("text"); + + b.Property("ConfigurationVersion") + .HasColumnType("integer"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("CredentialCiphertext") + .HasMaxLength(4096) + .HasColumnType("character varying(4096)"); + + b.Property("CredentialPresent") + .HasColumnType("boolean"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.Property("SelectedProviderConfigurationId") + .HasColumnType("uuid"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("VerificationStatus") + .HasMaxLength(80) + .HasColumnType("character varying(80)"); + + b.Property("VerifiedProviderConfigurationVersion") + .HasColumnType("integer"); + + b.Property("VerifiedUserConfigurationVersion") + .HasColumnType("integer"); + + b.HasKey("UserId"); + + b.HasIndex("SelectedProviderConfigurationId"); + + b.ToTable("UserRoutingConfigurations", null, t => + { + t.HasCheckConstraint("CK_UserRoutingConfigurations_CredentialConsistency", "(\"CredentialPresent\" AND \"CredentialCiphertext\" IS NOT NULL) OR (NOT \"CredentialPresent\" AND \"CredentialCiphertext\" IS NULL)"); + + t.HasCheckConstraint("CK_UserRoutingConfigurations_DefaultMode", "\"SelectedProviderConfigurationId\" IS NOT NULL OR (NOT \"CredentialPresent\" AND \"CredentialCiphertext\" IS NULL AND \"VerifiedUserConfigurationVersion\" IS NULL AND \"VerifiedProviderConfigurationVersion\" IS NULL AND \"VerificationStatus\" IS NULL)"); + + t.HasCheckConstraint("CK_UserRoutingConfigurations_VerifiedPair", "(\"VerifiedUserConfigurationVersion\" IS NULL AND \"VerifiedProviderConfigurationVersion\" IS NULL) OR (\"VerifiedUserConfigurationVersion\" IS NOT NULL AND \"VerifiedProviderConfigurationVersion\" IS NOT NULL)"); + + t.HasCheckConstraint("CK_UserRoutingConfigurations_Version", "\"ConfigurationVersion\" >= 1"); + }); + }); + + modelBuilder.Entity("ApplicationSettings", b => + { + b.HasOne("Wayfarer.Models.RoutingProviderConfiguration", "ActiveRoutingProviderConfiguration") + .WithMany() + .HasForeignKey("ActiveRoutingProviderConfigurationId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("ActiveRoutingProviderConfiguration"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("Wayfarer.Models.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("Wayfarer.Models.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Wayfarer.Models.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("Wayfarer.Models.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("TripTags", b => + { + b.HasOne("Wayfarer.Models.Tag", null) + .WithMany() + .HasForeignKey("TagId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Wayfarer.Models.Trip", null) + .WithMany() + .HasForeignKey("TripId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Wayfarer.Models.ApiToken", b => + { + b.HasOne("Wayfarer.Models.ApplicationUser", "User") + .WithMany("ApiTokens") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Wayfarer.Models.Area", b => + { + b.HasOne("Wayfarer.Models.Region", "Region") + .WithMany("Areas") + .HasForeignKey("RegionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Region"); + }); + + modelBuilder.Entity("Wayfarer.Models.Group", b => + { + b.HasOne("Wayfarer.Models.ApplicationUser", "Owner") + .WithMany("GroupsOwned") + .HasForeignKey("OwnerUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Owner"); + }); + + modelBuilder.Entity("Wayfarer.Models.GroupInvitation", b => + { + b.HasOne("Wayfarer.Models.Group", "Group") + .WithMany("Invitations") + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Wayfarer.Models.ApplicationUser", "Invitee") + .WithMany("GroupInvitationsReceived") + .HasForeignKey("InviteeUserId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Wayfarer.Models.ApplicationUser", "Inviter") + .WithMany("GroupInvitationsSent") + .HasForeignKey("InviterUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Group"); + + b.Navigation("Invitee"); + + b.Navigation("Inviter"); + }); + + modelBuilder.Entity("Wayfarer.Models.GroupMember", b => + { + b.HasOne("Wayfarer.Models.Group", "Group") + .WithMany("Members") + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Wayfarer.Models.ApplicationUser", "User") + .WithMany("GroupMemberships") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Group"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Wayfarer.Models.HiddenArea", b => + { + b.HasOne("Wayfarer.Models.ApplicationUser", "User") + .WithMany("HiddenAreas") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Wayfarer.Models.Location", b => + { + b.HasOne("Wayfarer.Models.ActivityType", "ActivityType") + .WithMany() + .HasForeignKey("ActivityTypeId"); + + b.HasOne("Wayfarer.Models.ApplicationUser", null) + .WithMany("Locations") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("ActivityType"); + }); + + modelBuilder.Entity("Wayfarer.Models.LocationImport", b => + { + b.HasOne("Wayfarer.Models.ApplicationUser", "User") + .WithMany("LocationImports") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Wayfarer.Models.LocationProviders.GeoapifyUsageAdmission", b => + { + b.HasOne("Wayfarer.Models.LocationProviders.GeoapifyUsageGuard", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Wayfarer.Models.LocationProviders.GeoapifyUsageGuard", b => + { + b.HasOne("Wayfarer.Models.ApplicationUser", null) + .WithOne() + .HasForeignKey("Wayfarer.Models.LocationProviders.GeoapifyUsageGuard", "UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Wayfarer.Models.LocationProviders.MapboxProductMeter", b => + { + b.HasOne("Wayfarer.Models.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Wayfarer.Models.LocationProviders.PersonalLocationProviderProfile", b => + { + b.HasOne("Wayfarer.Models.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Wayfarer.Models.LocationProviders.PersonalLocationProviderSelection", b => + { + b.HasOne("Wayfarer.Models.ApplicationUser", null) + .WithOne() + .HasForeignKey("Wayfarer.Models.LocationProviders.PersonalLocationProviderSelection", "UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Wayfarer.Models.LocationProviders.PersonalLocationProviderProfile", null) + .WithMany() + .HasForeignKey("UserId", "GeocodingProviderKey") + .HasPrincipalKey("UserId", "ProviderKey") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Wayfarer.Models.LocationProviders.PersonalLocationProviderProfile", null) + .WithMany() + .HasForeignKey("UserId", "RoutingProviderKey") + .HasPrincipalKey("UserId", "ProviderKey") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("FK_PersonalLocationProviderSelections_PersonalLocationProvide~1"); + }); + + modelBuilder.Entity("Wayfarer.Models.Place", b => + { + b.HasOne("Wayfarer.Models.Region", "Region") + .WithMany("Places") + .HasForeignKey("RegionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Region"); + }); + + modelBuilder.Entity("Wayfarer.Models.PlaceVisitCandidate", b => + { + b.HasOne("Wayfarer.Models.Place", "Place") + .WithMany() + .HasForeignKey("PlaceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Wayfarer.Models.ApplicationUser", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Place"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Wayfarer.Models.PlaceVisitEvent", b => + { + b.HasOne("Wayfarer.Models.Place", "Place") + .WithMany() + .HasForeignKey("PlaceId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Wayfarer.Models.ApplicationUser", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Place"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Wayfarer.Models.Region", b => + { + b.HasOne("Wayfarer.Models.Trip", "Trip") + .WithMany("Regions") + .HasForeignKey("TripId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Trip"); + }); + + modelBuilder.Entity("Wayfarer.Models.RoutingProviderProfileMapping", b => + { + b.HasOne("Wayfarer.Models.RoutingProviderConfiguration", "RoutingProviderConfiguration") + .WithMany("ProfileMappings") + .HasForeignKey("RoutingProviderConfigurationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Wayfarer.Models.TransportProfile", "TransportProfile") + .WithMany() + .HasForeignKey("TransportProfileId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("RoutingProviderConfiguration"); + + b.Navigation("TransportProfile"); + }); + + modelBuilder.Entity("Wayfarer.Models.Segment", b => + { + b.HasOne("Wayfarer.Models.Place", "FromPlace") + .WithMany() + .HasForeignKey("FromPlaceId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Wayfarer.Models.Place", "ToPlace") + .WithMany() + .HasForeignKey("ToPlaceId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Wayfarer.Models.TransportProfile", "TransportProfile") + .WithMany() + .HasForeignKey("TransportProfileId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Wayfarer.Models.Trip", "Trip") + .WithMany("Segments") + .HasForeignKey("TripId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("FromPlace"); + + b.Navigation("ToPlace"); + + b.Navigation("TransportProfile"); + + b.Navigation("Trip"); + }); + + modelBuilder.Entity("Wayfarer.Models.SegmentWaypoint", b => + { + b.HasOne("Wayfarer.Models.Place", "Place") + .WithMany() + .HasForeignKey("PlaceId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Wayfarer.Models.Segment", "Segment") + .WithMany("Waypoints") + .HasForeignKey("SegmentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Place"); + + b.Navigation("Segment"); + }); + + modelBuilder.Entity("Wayfarer.Models.Trip", b => + { + b.HasOne("Wayfarer.Models.ApplicationUser", "User") + .WithMany("Trips") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Wayfarer.Models.UserRoutingConfiguration", b => + { + b.HasOne("Wayfarer.Models.RoutingProviderConfiguration", "SelectedProviderConfiguration") + .WithMany() + .HasForeignKey("SelectedProviderConfigurationId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Wayfarer.Models.ApplicationUser", "User") + .WithOne() + .HasForeignKey("Wayfarer.Models.UserRoutingConfiguration", "UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("SelectedProviderConfiguration"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Wayfarer.Models.ApplicationUser", b => + { + b.Navigation("ApiTokens"); + + b.Navigation("GroupInvitationsReceived"); + + b.Navigation("GroupInvitationsSent"); + + b.Navigation("GroupMemberships"); + + b.Navigation("GroupsOwned"); + + b.Navigation("HiddenAreas"); + + b.Navigation("LocationImports"); + + b.Navigation("Locations"); + + b.Navigation("Trips"); + }); + + modelBuilder.Entity("Wayfarer.Models.Group", b => + { + b.Navigation("Invitations"); + + b.Navigation("Members"); + }); + + modelBuilder.Entity("Wayfarer.Models.Region", b => + { + b.Navigation("Areas"); + + b.Navigation("Places"); + }); + + modelBuilder.Entity("Wayfarer.Models.RoutingProviderConfiguration", b => + { + b.Navigation("ProfileMappings"); + }); + + modelBuilder.Entity("Wayfarer.Models.Segment", b => + { + b.Navigation("Waypoints"); + }); + + modelBuilder.Entity("Wayfarer.Models.Trip", b => + { + b.Navigation("Regions"); + + b.Navigation("Segments"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Migrations/20260823092929_EnforcePersonalProviderSelectionIntegrity.cs b/Migrations/20260823092929_EnforcePersonalProviderSelectionIntegrity.cs new file mode 100644 index 00000000..594fc7d5 --- /dev/null +++ b/Migrations/20260823092929_EnforcePersonalProviderSelectionIntegrity.cs @@ -0,0 +1,69 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Wayfarer.Migrations +{ + /// + public partial class EnforcePersonalProviderSelectionIntegrity : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddUniqueConstraint( + name: "AK_PersonalLocationProviderProfiles_UserId_ProviderKey", + table: "PersonalLocationProviderProfiles", + columns: new[] { "UserId", "ProviderKey" }); + + migrationBuilder.CreateIndex( + name: "IX_PersonalLocationProviderSelections_UserId_GeocodingProvider~", + table: "PersonalLocationProviderSelections", + columns: new[] { "UserId", "GeocodingProviderKey" }); + + migrationBuilder.CreateIndex( + name: "IX_PersonalLocationProviderSelections_UserId_RoutingProviderKey", + table: "PersonalLocationProviderSelections", + columns: new[] { "UserId", "RoutingProviderKey" }); + + migrationBuilder.AddForeignKey( + name: "FK_PersonalLocationProviderSelections_PersonalLocationProvider~", + table: "PersonalLocationProviderSelections", + columns: new[] { "UserId", "GeocodingProviderKey" }, + principalTable: "PersonalLocationProviderProfiles", + principalColumns: new[] { "UserId", "ProviderKey" }, + onDelete: ReferentialAction.Restrict); + + migrationBuilder.AddForeignKey( + name: "FK_PersonalLocationProviderSelections_PersonalLocationProvide~1", + table: "PersonalLocationProviderSelections", + columns: new[] { "UserId", "RoutingProviderKey" }, + principalTable: "PersonalLocationProviderProfiles", + principalColumns: new[] { "UserId", "ProviderKey" }, + onDelete: ReferentialAction.Restrict); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropForeignKey( + name: "FK_PersonalLocationProviderSelections_PersonalLocationProvider~", + table: "PersonalLocationProviderSelections"); + + migrationBuilder.DropForeignKey( + name: "FK_PersonalLocationProviderSelections_PersonalLocationProvide~1", + table: "PersonalLocationProviderSelections"); + + migrationBuilder.DropIndex( + name: "IX_PersonalLocationProviderSelections_UserId_GeocodingProvider~", + table: "PersonalLocationProviderSelections"); + + migrationBuilder.DropIndex( + name: "IX_PersonalLocationProviderSelections_UserId_RoutingProviderKey", + table: "PersonalLocationProviderSelections"); + + migrationBuilder.DropUniqueConstraint( + name: "AK_PersonalLocationProviderProfiles_UserId_ProviderKey", + table: "PersonalLocationProviderProfiles"); + } + } +} diff --git a/Migrations/ApplicationDbContextModelSnapshot.cs b/Migrations/ApplicationDbContextModelSnapshot.cs index f2b57207..3cb89a14 100644 --- a/Migrations/ApplicationDbContextModelSnapshot.cs +++ b/Migrations/ApplicationDbContextModelSnapshot.cs @@ -1188,6 +1188,10 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasKey("UserId"); + b.HasIndex("UserId", "GeocodingProviderKey"); + + b.HasIndex("UserId", "RoutingProviderKey"); + b.ToTable("PersonalLocationProviderSelections", t => { t.HasCheckConstraint("CK_PersonalProviderSelection_Geocoding", "\"GeocodingProviderKey\" IS NULL OR \"GeocodingProviderKey\" IN ('geoapify', 'mapbox')"); @@ -2093,6 +2097,19 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasForeignKey("Wayfarer.Models.LocationProviders.PersonalLocationProviderSelection", "UserId") .OnDelete(DeleteBehavior.Cascade) .IsRequired(); + + b.HasOne("Wayfarer.Models.LocationProviders.PersonalLocationProviderProfile", null) + .WithMany() + .HasForeignKey("UserId", "GeocodingProviderKey") + .HasPrincipalKey("UserId", "ProviderKey") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Wayfarer.Models.LocationProviders.PersonalLocationProviderProfile", null) + .WithMany() + .HasForeignKey("UserId", "RoutingProviderKey") + .HasPrincipalKey("UserId", "ProviderKey") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("FK_PersonalLocationProviderSelections_PersonalLocationProvide~1"); }); modelBuilder.Entity("Wayfarer.Models.Place", b => diff --git a/Models/ApplicationDbContext.LocationProviders.cs b/Models/ApplicationDbContext.LocationProviders.cs new file mode 100644 index 00000000..a3204593 --- /dev/null +++ b/Models/ApplicationDbContext.LocationProviders.cs @@ -0,0 +1,19 @@ +using Microsoft.EntityFrameworkCore; +using Wayfarer.Models.LocationProviders; + +namespace Wayfarer.Models; + +/// Exposes only the cohesive personal location-provider persistence surface. +public partial class ApplicationDbContext +{ + /// Gets personal provider profiles. + public DbSet PersonalLocationProviderProfiles { get; set; } + /// Gets independent active provider selections. + public DbSet PersonalLocationProviderSelections { get; set; } + /// Gets Geoapify shared-pool guard rows. + public DbSet GeoapifyUsageGuards { get; set; } + /// Gets rolling Geoapify admissions. + public DbSet GeoapifyUsageAdmissions { get; set; } + /// Gets independent Mapbox product meters. + public DbSet MapboxProductMeters { get; set; } +} diff --git a/Models/ApplicationDbContext.cs b/Models/ApplicationDbContext.cs index 76db598c..943feae0 100644 --- a/Models/ApplicationDbContext.cs +++ b/Models/ApplicationDbContext.cs @@ -1,11 +1,10 @@ using Microsoft.AspNetCore.Identity.EntityFrameworkCore; using Microsoft.EntityFrameworkCore; using Npgsql.EntityFrameworkCore.PostgreSQL; -using Wayfarer.Models.LocationProviders; namespace Wayfarer.Models { - public class ApplicationDbContext : IdentityDbContext + public partial class ApplicationDbContext : IdentityDbContext { private readonly IServiceProvider _serviceProvider; @@ -18,16 +17,6 @@ public ApplicationDbContext(DbContextOptions options, public DbSet Locations { get; set; } public DbSet ApiTokens { get; set; } - /// Gets personal provider profiles. - public DbSet PersonalLocationProviderProfiles { get; set; } - /// Gets independent active provider selections. - public DbSet PersonalLocationProviderSelections { get; set; } - /// Gets Geoapify shared-pool guard rows. - public DbSet GeoapifyUsageGuards { get; set; } - /// Gets rolling Geoapify admissions. - public DbSet GeoapifyUsageAdmissions { get; set; } - /// Gets independent Mapbox product meters. - public DbSet MapboxProductMeters { get; set; } public DbSet ApplicationUsers { get; set; } public DbSet AuditLogs { get; set; } @@ -87,8 +76,6 @@ protected override void OnModelCreating(ModelBuilder builder) .Property(at => at.UserId) .IsRequired(); - // Legacy Mapbox plaintext is migration recovery state, never a generic token/contact source. - builder.Entity().HasQueryFilter(at => at.Name.Trim().ToLower() != "mapbox"); builder.Entity() .Property(at => at.CreatedAt) diff --git a/Models/Configuration/PersonalLocationProviderConfiguration.cs b/Models/Configuration/PersonalLocationProviderConfiguration.cs index cf502354..cdc98757 100644 --- a/Models/Configuration/PersonalLocationProviderConfiguration.cs +++ b/Models/Configuration/PersonalLocationProviderConfiguration.cs @@ -4,12 +4,20 @@ namespace Wayfarer.Models.Configuration; +/// Prevents generic token/authentication readers from exposing legacy Mapbox recovery plaintext. +public sealed class LegacyProviderTokenReadConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) => + builder.HasQueryFilter(item => item.Name.Trim().ToLower() != "mapbox"); +} + /// Defines bounded relational authority for personal provider profiles and selections. public sealed class PersonalLocationProviderConfiguration : IEntityTypeConfiguration { public void Configure(EntityTypeBuilder builder) { builder.HasKey(item => item.Id); + builder.HasAlternateKey(item => new { item.UserId, item.ProviderKey }); builder.HasIndex(item => new { item.UserId, item.ProviderKey }).IsUnique(); builder.HasOne().WithMany().HasForeignKey(item => item.UserId).OnDelete(DeleteBehavior.Cascade); builder.Property(item => item.RowVersion).HasColumnName("xmin").IsRowVersion().ValueGeneratedOnAddOrUpdate(); @@ -29,6 +37,12 @@ public void Configure(EntityTypeBuilder build { builder.HasKey(item => item.UserId); builder.HasOne().WithOne().HasForeignKey(item => item.UserId).OnDelete(DeleteBehavior.Cascade); + builder.HasOne().WithMany() + .HasForeignKey(item => new { item.UserId, item.GeocodingProviderKey }) + .HasPrincipalKey(item => new { item.UserId, item.ProviderKey }).OnDelete(DeleteBehavior.Restrict); + builder.HasOne().WithMany() + .HasForeignKey(item => new { item.UserId, item.RoutingProviderKey }) + .HasPrincipalKey(item => new { item.UserId, item.ProviderKey }).OnDelete(DeleteBehavior.Restrict); builder.Property(item => item.RowVersion).HasColumnName("xmin").IsRowVersion().ValueGeneratedOnAddOrUpdate(); builder.ToTable(table => { diff --git a/Models/LocationProviders/LegacyMapboxMigration.cs b/Models/LocationProviders/LegacyMapboxMigration.cs index 0351df6a..da75afb2 100644 --- a/Models/LocationProviders/LegacyMapboxMigration.cs +++ b/Models/LocationProviders/LegacyMapboxMigration.cs @@ -8,6 +8,7 @@ public enum LegacyMapboxMigrationState public sealed record PersonalCredentialRead(bool Succeeded, string? Credential) { public static PersonalCredentialRead Unavailable { get; } = new(false, null); + public override string ToString() => $"PersonalCredentialRead {{ Succeeded = {Succeeded} }}"; } /// Represents the non-destructive retirement decision. diff --git a/Services/LocationProviders/LegacyMapboxMigrationService.cs b/Services/LocationProviders/LegacyMapboxMigrationService.cs index ad8d61a3..cd39314c 100644 --- a/Services/LocationProviders/LegacyMapboxMigrationService.cs +++ b/Services/LocationProviders/LegacyMapboxMigrationService.cs @@ -20,7 +20,14 @@ public async Task MigrateAsync(string userId, Cance var values = legacyRows.Select(item => item.Token!.Trim()).Distinct(StringComparer.Ordinal).ToArray(); if (values.Length == 0) - return await CompleteAsync(new(LegacyMapboxMigrationState.None, 0, false), transaction, cancellationToken); + { + if (profile?.RevokedAt != null) profile.LegacyMigrationState = LegacyMapboxMigrationState.Revoked; + else if (!string.IsNullOrEmpty(profile?.ProtectedCredential) && !credentials.Read(profile).Succeeded) + profile.LegacyMigrationState = LegacyMapboxMigrationState.ProtectedCredentialUnavailable; + if (profile != null) await dbContext.SaveChangesAsync(cancellationToken); + return await CompleteAsync(new(profile?.LegacyMigrationState ?? LegacyMapboxMigrationState.None, 0, + profile != null && credentials.Read(profile).Succeeded), transaction, cancellationToken); + } if (profile?.RevokedAt != null) { profile.LegacyMigrationState = LegacyMapboxMigrationState.Revoked; diff --git a/Services/LocationProviders/PersonalProviderContactGate.cs b/Services/LocationProviders/PersonalProviderContactGate.cs index f31780ec..017e8f0d 100644 --- a/Services/LocationProviders/PersonalProviderContactGate.cs +++ b/Services/LocationProviders/PersonalProviderContactGate.cs @@ -1,4 +1,5 @@ using Microsoft.EntityFrameworkCore; +using System.Text.Json.Serialization; using Wayfarer.Models; using Wayfarer.Models.LocationProviders; @@ -195,9 +196,24 @@ public enum PersonalProviderAdmissionCategory { Admitted, InvalidCost, NoProviderSelected, UnsupportedProvider, UnsupportedProduct, Unauthorized, Unverified, CredentialUnavailable, Exhausted } /// Contains server-internal immutable contact authority; it must never be serialized. -public sealed record PersonalProviderAuthoritySnapshot(string UserId, string ProviderKey, - PersonalProviderCapability Capability, string Credential, int CredentialGeneration, - int CapabilityGeneration, int SelectionGeneration); +public sealed class PersonalProviderAuthoritySnapshot +{ + public PersonalProviderAuthoritySnapshot(string userId, string providerKey, PersonalProviderCapability capability, + string credential, int credentialGeneration, int capabilityGeneration, int selectionGeneration) + { + UserId = userId; ProviderKey = providerKey; Capability = capability; Credential = credential; + CredentialGeneration = credentialGeneration; CapabilityGeneration = capabilityGeneration; + SelectionGeneration = selectionGeneration; + } + public string UserId { get; } + public string ProviderKey { get; } + public PersonalProviderCapability Capability { get; } + [JsonIgnore] public string Credential { get; } + public int CredentialGeneration { get; } + public int CapabilityGeneration { get; } + public int SelectionGeneration { get; } + public override string ToString() => $"PersonalProviderAuthoritySnapshot {{ ProviderKey = {ProviderKey}, Capability = {Capability}, CredentialGeneration = {CredentialGeneration}, CapabilityGeneration = {CapabilityGeneration}, SelectionGeneration = {SelectionGeneration} }}"; +} /// Contains only bounded usage status. public sealed record PersonalProviderUsageStatus(int Used, int Limit, string Unit, DateTimeOffset? RollingCutoff, DateOnly? CycleStart); diff --git a/Services/LocationProviders/PersonalProviderCredentialService.cs b/Services/LocationProviders/PersonalProviderCredentialService.cs index ba2ae874..e735d6e6 100644 --- a/Services/LocationProviders/PersonalProviderCredentialService.cs +++ b/Services/LocationProviders/PersonalProviderCredentialService.cs @@ -48,6 +48,27 @@ public void Revoke(PersonalLocationProviderProfile profile) profile.UpdatedAt = DateTimeOffset.UtcNow; } + /// Records only bounded verification and binds it to current credential/capability generations. + public void RecordVerification(PersonalLocationProviderProfile profile, PersonalProviderCapability capability, + PersonalProviderVerification verification) + { + if (verification is < PersonalProviderVerification.Unverified or > PersonalProviderVerification.Unavailable) + throw new ArgumentOutOfRangeException(nameof(verification)); + if (capability == PersonalProviderCapability.Geocoding) + { + profile.GeocodingVerification = verification; + profile.GeocodingVerifiedCredentialGeneration = verification == PersonalProviderVerification.Verified ? profile.CredentialGeneration : null; + profile.GeocodingVerifiedConfigurationGeneration = verification == PersonalProviderVerification.Verified ? profile.GeocodingGeneration : null; + } + else + { + profile.RoutingVerification = verification; + profile.RoutingVerifiedCredentialGeneration = verification == PersonalProviderVerification.Verified ? profile.CredentialGeneration : null; + profile.RoutingVerifiedConfigurationGeneration = verification == PersonalProviderVerification.Verified ? profile.RoutingGeneration : null; + } + profile.UpdatedAt = DateTimeOffset.UtcNow; + } + private IDataProtector Protector(PersonalLocationProviderProfile profile) => _provider .CreateProtector(ProtectionPurpose).CreateProtector("credential") .CreateProtector(profile.ProviderKey).CreateProtector(profile.UserId); diff --git a/tests/Wayfarer.Tests/Controllers/TripEditorPlaceControllerTests.cs b/tests/Wayfarer.Tests/Controllers/TripEditorPlaceControllerTests.cs index 48061acc..7d5d4199 100644 --- a/tests/Wayfarer.Tests/Controllers/TripEditorPlaceControllerTests.cs +++ b/tests/Wayfarer.Tests/Controllers/TripEditorPlaceControllerTests.cs @@ -334,7 +334,7 @@ public async Task ReverseGeocodeProviderTimeoutSavesManualAddressAndReturnsWarni } [Fact] - public async Task ReverseGeocodeRequestCancellationPropagates() + public async Task LegacyMapboxCredential_CannotReachOutboundHandler() { using var cancellation = new CancellationTokenSource(); using var db = CreateDbContext(); @@ -354,10 +354,12 @@ public async Task ReverseGeocodeRequestCancellationPropagates() NullLogger.Instance)); ConfigureControllerWithUserRole(controller, "owner-user"); - await Assert.ThrowsAnyAsync(() => - SendJson(controller, c => c.CreatePlace(trip.Id, region.Id, cancellation.Token), ValidCreateBody("Geo", reverseGeocode: true))); + var result = await SendJson(controller, + c => c.CreatePlace(trip.Id, region.Id, cancellation.Token), ValidCreateBody("Geo", reverseGeocode: true)); - Assert.True(handler.RequestCancellationReachedOutboundHandler); - Assert.DoesNotContain(db.Places, p => p.Name == "Geo"); + var envelope = AssertMutation(result); + Assert.False(handler.RequestCancellationReachedOutboundHandler); + Assert.Equal("reverse-geocode-unavailable", Assert.Single(envelope.Warnings).Code); + Assert.Contains(db.Places, p => p.Name == "Geo"); } } diff --git a/tests/Wayfarer.Tests/Controllers/UserApiTokenControllerTests.cs b/tests/Wayfarer.Tests/Controllers/UserApiTokenControllerTests.cs index bd576f4d..135cfebe 100644 --- a/tests/Wayfarer.Tests/Controllers/UserApiTokenControllerTests.cs +++ b/tests/Wayfarer.Tests/Controllers/UserApiTokenControllerTests.cs @@ -2,6 +2,7 @@ using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; using Microsoft.AspNetCore.Mvc.ViewFeatures; using Microsoft.Extensions.Logging.Abstractions; using Moq; @@ -84,11 +85,12 @@ public async Task StoreThirdPartyToken_RejectsDuplicateName() var redirect = Assert.IsType(result); Assert.Equal("Index", redirect.ActionName); - Assert.Equal(1, db.ApiTokens.Count(t => t.UserId == user.Id)); + Assert.Equal("LocationProviderSettings", redirect.ControllerName); + Assert.Equal(1, db.ApiTokens.IgnoreQueryFilters().Count(t => t.UserId == user.Id)); } [Fact] - public async Task StoreThirdPartyToken_CreatesWhenUnique() + public async Task StoreThirdPartyToken_RoutesMapboxToProtectedProviderSettings() { var db = CreateDbContext(); var user = TestDataFixtures.CreateUser(id: "u1", username: "alice"); @@ -100,7 +102,8 @@ public async Task StoreThirdPartyToken_CreatesWhenUnique() var redirect = Assert.IsType(result); Assert.Equal("Index", redirect.ActionName); - Assert.Single(db.ApiTokens.Where(t => t.UserId == user.Id && t.Name == "Mapbox")); + Assert.Equal("LocationProviderSettings", redirect.ControllerName); + Assert.Empty(db.ApiTokens.IgnoreQueryFilters().Where(t => t.UserId == user.Id)); } [Fact] diff --git a/tests/Wayfarer.Tests/Models/PersonalProviderUsagePostgresTests.cs b/tests/Wayfarer.Tests/Models/PersonalProviderUsagePostgresTests.cs index d257d596..6f8c5cb0 100644 --- a/tests/Wayfarer.Tests/Models/PersonalProviderUsagePostgresTests.cs +++ b/tests/Wayfarer.Tests/Models/PersonalProviderUsagePostgresTests.cs @@ -66,6 +66,68 @@ await SeedVerifiedProfileAsync(user.Id, PersonalLocationProvider.Mapbox, PersonalProviderProduct.Directions, 1)).Succeeded); } + [PostgresFact] + public async Task GeoapifySharedPool_RetainsUsageAcrossGuardChangesAndCleansExpiredRows() + { + fixture.RequireAvailable(); + var user = await fixture.CreateUserAsync(); + var protection = new EphemeralDataProtectionProvider(); + await SeedVerifiedProfileAsync(user.Id, PersonalLocationProvider.Geoapify, + PersonalProviderCapability.Geocoding, protection, alsoRouting: true); + await using (var setup = fixture.CreateContext()) + { + setup.GeoapifyUsageGuards.Add(new() { UserId = user.Id, Enabled = true, CreditLimit = 3 }); + await setup.SaveChangesAsync(); + await setup.Database.ExecuteSqlInterpolatedAsync($$""" + INSERT INTO "GeoapifyUsageAdmissions" ("UserId", "Credits", "Product", "AdmittedAt") + VALUES ({{user.Id}}, 100, 1, clock_timestamp() - interval '25 hours') + """); + } + await using var context = fixture.CreateContext(); + var gate = Gate(context, protection); + Assert.True((await gate.AdmitAsync(user.Id, PersonalProviderCapability.Geocoding, + PersonalProviderProduct.Geocoding, 2)).Succeeded); + Assert.Equal(PersonalProviderAdmissionCategory.Exhausted, (await gate.AdmitAsync(user.Id, + PersonalProviderCapability.Routing, PersonalProviderProduct.Routing, 2)).Category); + + var guard = await context.GeoapifyUsageGuards.SingleAsync(item => item.UserId == user.Id); + guard.Enabled = false; guard.CreditLimit = 1; await context.SaveChangesAsync(); + Assert.True((await gate.AdmitAsync(user.Id, PersonalProviderCapability.Routing, + PersonalProviderProduct.Routing, 1)).Succeeded); + guard = await context.GeoapifyUsageGuards.SingleAsync(item => item.UserId == user.Id); + guard.Enabled = true; guard.CreditLimit = 3; await context.SaveChangesAsync(); + Assert.Equal(PersonalProviderAdmissionCategory.Exhausted, (await gate.AdmitAsync(user.Id, + PersonalProviderCapability.Geocoding, PersonalProviderProduct.Geocoding, 1)).Category); + guard = await context.GeoapifyUsageGuards.SingleAsync(item => item.UserId == user.Id); + guard.CreditLimit = 4; await context.SaveChangesAsync(); + Assert.True((await gate.AdmitAsync(user.Id, PersonalProviderCapability.Geocoding, + PersonalProviderProduct.Geocoding, 1)).Succeeded); + Assert.Equal(4, await context.GeoapifyUsageAdmissions.Where(item => item.UserId == user.Id).SumAsync(item => item.Credits)); + } + + [PostgresFact] + public async Task MapboxConcurrentLastDirection_HasExactlyOneWinner() + { + fixture.RequireAvailable(); + var user = await fixture.CreateUserAsync(); + var protection = new EphemeralDataProtectionProvider(); + await SeedVerifiedProfileAsync(user.Id, PersonalLocationProvider.Mapbox, + PersonalProviderCapability.Routing, protection); + await using (var setup = fixture.CreateContext()) + { + setup.MapboxProductMeters.Add(new() + { UserId = user.Id, Product = PersonalProviderProduct.Directions, Enabled = true, Limit = 1, CycleStart = new(1970, 1, 1) }); + await setup.SaveChangesAsync(); + } + await using var firstContext = fixture.CreateContext(); + await using var secondContext = fixture.CreateContext(); + var results = await Task.WhenAll( + Gate(firstContext, protection).AdmitAsync(user.Id, PersonalProviderCapability.Routing, PersonalProviderProduct.Directions, 1), + Gate(secondContext, protection).AdmitAsync(user.Id, PersonalProviderCapability.Routing, PersonalProviderProduct.Directions, 1)); + Assert.Single(results, item => item.Succeeded); + Assert.Single(results, item => item.Category == PersonalProviderAdmissionCategory.Exhausted); + } + private async Task SeedVerifiedProfileAsync(string userId, PersonalLocationProvider provider, PersonalProviderCapability capability, IDataProtectionProvider protection, bool alsoRouting = false) { diff --git a/tests/Wayfarer.Tests/Services/ApiTokenServiceTests.cs b/tests/Wayfarer.Tests/Services/ApiTokenServiceTests.cs index b28cdf70..4af56b51 100644 --- a/tests/Wayfarer.Tests/Services/ApiTokenServiceTests.cs +++ b/tests/Wayfarer.Tests/Services/ApiTokenServiceTests.cs @@ -117,7 +117,7 @@ public async Task StoreThirdPartyToken_SavesProvidedToken() } [Fact] - public async Task ValidateApiTokenAsync_WorksWithThirdPartyPlainTokens() + public async Task ValidateApiTokenAsync_DoesNotTreatLegacyProviderCredentialAsInboundToken() { var db = CreateDbContext(); var user = TestDataFixtures.CreateUser(id: "u6", username: "frank"); @@ -130,7 +130,7 @@ public async Task ValidateApiTokenAsync_WorksWithThirdPartyPlainTokens() var valid = await service.ValidateApiTokenAsync(user.Id, "mapbox-token-123"); var invalid = await service.ValidateApiTokenAsync(user.Id, "wrong"); - Assert.True(valid); + Assert.False(valid); Assert.False(invalid); } diff --git a/tests/Wayfarer.Tests/Services/MobileCurrentUserAccessorTests.cs b/tests/Wayfarer.Tests/Services/MobileCurrentUserAccessorTests.cs index f67b5903..be2f8683 100644 --- a/tests/Wayfarer.Tests/Services/MobileCurrentUserAccessorTests.cs +++ b/tests/Wayfarer.Tests/Services/MobileCurrentUserAccessorTests.cs @@ -551,7 +551,7 @@ public async Task GetCurrentUserAsync_PrefersHashedTokenOverPlainText() } [Fact] - public async Task GetCurrentUserAsync_FallsBackToPlainToken_WhenHashNotSet() + public async Task GetCurrentUserAsync_DoesNotAuthenticateWithLegacyProviderPlaintext() { // Arrange - Third-party tokens only have Token set (no hash) var db = CreateDbContext(); @@ -581,9 +581,7 @@ public async Task GetCurrentUserAsync_FallsBackToPlainToken_WhenHashNotSet() // Act var result = await accessor.GetCurrentUserAsync(); - // Assert - Should match via plain Token - Assert.NotNull(result); - Assert.Equal(user.Id, result.Id); + Assert.Null(result); } #endregion diff --git a/tests/Wayfarer.Tests/Services/PersonalLocationProviderFoundationTests.cs b/tests/Wayfarer.Tests/Services/PersonalLocationProviderFoundationTests.cs index 848d1ce4..75ffa1e2 100644 --- a/tests/Wayfarer.Tests/Services/PersonalLocationProviderFoundationTests.cs +++ b/tests/Wayfarer.Tests/Services/PersonalLocationProviderFoundationTests.cs @@ -1,5 +1,6 @@ using Microsoft.AspNetCore.DataProtection; using Microsoft.EntityFrameworkCore; +using System.Text.Json; using Wayfarer.Models; using Wayfarer.Models.LocationProviders; using Wayfarer.Services.LocationProviders; @@ -144,4 +145,91 @@ public void PersistentKeyRing_ReadsCredentialAfterServiceRecreation() Directory.Delete(path, recursive: true); } } + + [Fact] + public void ContactAuthority_RedactsCredentialFromSerializationAndDiagnostics() + { + var snapshot = new PersonalProviderAuthoritySnapshot("user", "mapbox", + PersonalProviderCapability.Geocoding, "never-disclose", 2, 3, 4); + + Assert.DoesNotContain("never-disclose", snapshot.ToString(), StringComparison.Ordinal); + Assert.DoesNotContain("never-disclose", JsonSerializer.Serialize(snapshot), StringComparison.Ordinal); + Assert.DoesNotContain("never-disclose", new PersonalCredentialRead(true, "never-disclose").ToString(), StringComparison.Ordinal); + } + + [Fact] + public void ReplacementAndRevocation_AdvanceGenerationAndInvalidateBothCapabilities() + { + var profile = PersonalLocationProviderProfile.Create("generation-user", PersonalLocationProvider.Mapbox); + var owner = new PersonalProviderCredentialService(new EphemeralDataProtectionProvider()); + owner.Replace(profile, "first"); + profile.SetAuthorization(PersonalProviderCapability.Geocoding, true); + profile.SetAuthorization(PersonalProviderCapability.Routing, true); + owner.RecordVerification(profile, PersonalProviderCapability.Geocoding, PersonalProviderVerification.Verified); + owner.RecordVerification(profile, PersonalProviderCapability.Routing, PersonalProviderVerification.Verified); + var beforeReplacement = profile.CredentialGeneration; + + owner.Replace(profile, "second"); + + Assert.True(profile.CredentialGeneration > beforeReplacement); + Assert.Equal(PersonalProviderVerification.Unverified, profile.GeocodingVerification); + Assert.Equal(PersonalProviderVerification.Unverified, profile.RoutingVerification); + Assert.True(profile.GeocodingAuthorized); + Assert.True(profile.RoutingAuthorized); + var beforeRevocation = profile.CredentialGeneration; + + owner.Revoke(profile); + + Assert.True(profile.CredentialGeneration > beforeRevocation); + Assert.False(profile.GeocodingAuthorized); + Assert.False(profile.RoutingAuthorized); + Assert.Null(profile.ProtectedCredential); + } + + [Fact] + public async Task LegacyMigration_SameValueAliasesAndRerunConverge() + { + var db = CreateDbContext(); + var user = TestDataFixtures.CreateUser(id: "alias-user", username: "alias"); + db.Users.Add(user); + db.ApiTokens.AddRange( + new ApiToken { Id = 8201, Name = "Mapbox", Token = "same", UserId = user.Id, User = user }, + new ApiToken { Id = 8202, Name = " mapBOX ", Token = "same", UserId = user.Id, User = user }); + await db.SaveChangesAsync(); + var service = new LegacyMapboxMigrationService(db, + new PersonalProviderCredentialService(new EphemeralDataProtectionProvider())); + + var first = await service.MigrateAsync(user.Id); + var rerun = await service.MigrateAsync(user.Id); + + Assert.Equal(2, first.RetiredLegacyRows); + Assert.True(first.ProtectedCredentialReady); + Assert.Equal(LegacyMapboxMigrationState.Migrated, rerun.State); + Assert.Empty(await db.ApiTokens.IgnoreQueryFilters().ToListAsync()); + } + + [Fact] + public async Task LegacyMigration_InvalidCiphertextAndRevocationPreservePlaintext() + { + var db = CreateDbContext(); + var invalidUser = TestDataFixtures.CreateUser(id: "invalid-user", username: "invalid"); + var revokedUser = TestDataFixtures.CreateUser(id: "revoked-user", username: "revoked"); + db.Users.AddRange(invalidUser, revokedUser); + var invalid = PersonalLocationProviderProfile.Create(invalidUser.Id, PersonalLocationProvider.Mapbox); + invalid.ProtectedCredential = "invalid-ciphertext"; + var revoked = PersonalLocationProviderProfile.Create(revokedUser.Id, PersonalLocationProvider.Mapbox); + revoked.RevokedAt = DateTimeOffset.UtcNow; + db.AddRange(invalid, revoked); + db.ApiTokens.AddRange( + new ApiToken { Id = 8301, Name = "Mapbox", Token = "legacy-invalid", UserId = invalidUser.Id, User = invalidUser }, + new ApiToken { Id = 8302, Name = "Mapbox", Token = "legacy-revoked", UserId = revokedUser.Id, User = revokedUser }); + await db.SaveChangesAsync(); + var service = new LegacyMapboxMigrationService(db, + new PersonalProviderCredentialService(new EphemeralDataProtectionProvider())); + + Assert.Equal(LegacyMapboxMigrationState.ProtectedCredentialUnavailable, + (await service.MigrateAsync(invalidUser.Id)).State); + Assert.Equal(LegacyMapboxMigrationState.Revoked, (await service.MigrateAsync(revokedUser.Id)).State); + Assert.Equal(2, await db.ApiTokens.IgnoreQueryFilters().CountAsync()); + } } From 54a3d12d5bf441840c47a3daec0b9ca2f74c247d Mon Sep 17 00:00:00 2001 From: Stef Kariotidis Date: Sun, 23 Aug 2026 12:39:06 +0300 Subject: [PATCH 08/13] fix(providers): preserve matching migration authority --- .../LocationProviderSettingsController.cs | 4 +-- .../LegacyMapboxMigrationService.cs | 27 ++++++++++++------- ...PersonalLocationProviderFoundationTests.cs | 27 ++++++++++++++++++- 3 files changed, 45 insertions(+), 13 deletions(-) diff --git a/Areas/User/Controllers/LocationProviderSettingsController.cs b/Areas/User/Controllers/LocationProviderSettingsController.cs index 0344c48e..bc586931 100644 --- a/Areas/User/Controllers/LocationProviderSettingsController.cs +++ b/Areas/User/Controllers/LocationProviderSettingsController.cs @@ -44,9 +44,9 @@ public async Task SaveProfile(LocationProviderProfileInput input, var selection = await dbContext.PersonalLocationProviderSelections.SingleOrDefaultAsync( item => item.UserId == userId, cancellationToken) ?? PersonalLocationProviderSelection.Create(userId); if (dbContext.Entry(selection).State == EntityState.Detached) dbContext.Add(selection); - if (input.ActiveForGeocoding) selection.Select(PersonalProviderCapability.Geocoding, provider); + if (input.ActiveForGeocoding && input.GeocodingAuthorized) selection.Select(PersonalProviderCapability.Geocoding, provider); else if (selection.GeocodingProviderKey == key) selection.Select(PersonalProviderCapability.Geocoding, null); - if (input.ActiveForRouting) selection.Select(PersonalProviderCapability.Routing, provider); + if (input.ActiveForRouting && input.RoutingAuthorized) selection.Select(PersonalProviderCapability.Routing, provider); else if (selection.RoutingProviderKey == key) selection.Select(PersonalProviderCapability.Routing, null); await dbContext.SaveChangesAsync(cancellationToken); return RedirectToAction(nameof(Index)); diff --git a/Services/LocationProviders/LegacyMapboxMigrationService.cs b/Services/LocationProviders/LegacyMapboxMigrationService.cs index cd39314c..1f5647ad 100644 --- a/Services/LocationProviders/LegacyMapboxMigrationService.cs +++ b/Services/LocationProviders/LegacyMapboxMigrationService.cs @@ -52,21 +52,13 @@ public async Task MigrateAsync(string userId, Cance } if (!string.Equals(protectedRead.Credential, values[0], StringComparison.Ordinal)) return await PreserveConflictAsync(profile, userId, transaction, cancellationToken); + await EnsureGeocodingAuthorityAsync(profile, userId, cancellationToken); } else { credentials.Replace(profile, values[0]); - profile.SetAuthorization(PersonalProviderCapability.Geocoding, true); profile.SetAuthorization(PersonalProviderCapability.Routing, false); - var selection = await dbContext.Set() - .SingleOrDefaultAsync(item => item.UserId == userId, cancellationToken); - if (selection == null) - { - selection = PersonalLocationProviderSelection.Create(userId); - dbContext.Add(selection); - } - if (selection.GeocodingProviderKey == null) - selection.Select(PersonalProviderCapability.Geocoding, PersonalLocationProvider.Mapbox); + await EnsureGeocodingAuthorityAsync(profile, userId, cancellationToken); await dbContext.SaveChangesAsync(cancellationToken); var protectedRead = credentials.Read(profile); if (!protectedRead.Succeeded || !string.Equals(protectedRead.Credential, values[0], StringComparison.Ordinal)) @@ -93,6 +85,21 @@ public async Task MigrateAsync(string userId, Cance : dbContext.Set().SingleOrDefaultAsync( item => item.UserId == userId && item.ProviderKey == "mapbox", cancellationToken); + private async Task EnsureGeocodingAuthorityAsync( + PersonalLocationProviderProfile profile, string userId, CancellationToken cancellationToken) + { + profile.SetAuthorization(PersonalProviderCapability.Geocoding, true); + var selection = await dbContext.Set() + .SingleOrDefaultAsync(item => item.UserId == userId, cancellationToken); + if (selection == null) + { + selection = PersonalLocationProviderSelection.Create(userId); + dbContext.Add(selection); + } + if (selection.GeocodingProviderKey == null) + selection.Select(PersonalProviderCapability.Geocoding, PersonalLocationProvider.Mapbox); + } + private async Task> LockLegacyRowsAsync(string userId, CancellationToken cancellationToken) { var rows = dbContext.Database.IsNpgsql() diff --git a/tests/Wayfarer.Tests/Services/PersonalLocationProviderFoundationTests.cs b/tests/Wayfarer.Tests/Services/PersonalLocationProviderFoundationTests.cs index 75ffa1e2..31f1516b 100644 --- a/tests/Wayfarer.Tests/Services/PersonalLocationProviderFoundationTests.cs +++ b/tests/Wayfarer.Tests/Services/PersonalLocationProviderFoundationTests.cs @@ -89,7 +89,8 @@ public async Task LegacyMigration_ProtectsBeforeRetiringAndPreservesUnrelatedTok db.Users.Add(user); db.ApiTokens.AddRange( new ApiToken { Id = 8001, Name = " MapBOX ", Token = "legacy-key", UserId = user.Id, User = user }, - new ApiToken { Id = 8002, Name = "mobile", TokenHash = "hash", UserId = user.Id, User = user }); + new ApiToken { Id = 8002, Name = "mobile", TokenHash = "hash", UserId = user.Id, User = user }, + new ApiToken { Id = 8003, Name = "MyMapboxBackup", Token = "unrelated", UserId = user.Id, User = user }); await db.SaveChangesAsync(); var owner = new PersonalProviderCredentialService(new EphemeralDataProtectionProvider()); @@ -102,6 +103,7 @@ public async Task LegacyMigration_ProtectsBeforeRetiringAndPreservesUnrelatedTok Assert.False(profile.RoutingAuthorized); Assert.DoesNotContain(await db.ApiTokens.IgnoreQueryFilters().ToListAsync(), item => PersonalProviderKeys.IsLegacyMapbox(item.Name)); Assert.Contains(await db.ApiTokens.ToListAsync(), item => item.Name == "mobile"); + Assert.Contains(await db.ApiTokens.ToListAsync(), item => item.Name == "MyMapboxBackup"); } [Fact] @@ -232,4 +234,27 @@ public async Task LegacyMigration_InvalidCiphertextAndRevocationPreservePlaintex Assert.Equal(LegacyMapboxMigrationState.Revoked, (await service.MigrateAsync(revokedUser.Id)).State); Assert.Equal(2, await db.ApiTokens.IgnoreQueryFilters().CountAsync()); } + + [Fact] + public async Task LegacyMigration_MatchingProtectedValueWinsAndEnablesOnlyGeocoding() + { + var db = CreateDbContext(); + var user = TestDataFixtures.CreateUser(id: "matching-user", username: "matching"); + db.Users.Add(user); + var owner = new PersonalProviderCredentialService(new EphemeralDataProtectionProvider()); + var profile = PersonalLocationProviderProfile.Create(user.Id, PersonalLocationProvider.Mapbox); + owner.Replace(profile, "matching-key"); + db.Add(profile); + db.ApiTokens.Add(new ApiToken + { Id = 8401, Name = "Mapbox", Token = "matching-key", UserId = user.Id, User = user }); + await db.SaveChangesAsync(); + + var result = await new LegacyMapboxMigrationService(db, owner).MigrateAsync(user.Id); + + Assert.True(result.ProtectedCredentialReady); + Assert.Equal("matching-key", owner.Read(profile).Credential); + Assert.True(profile.GeocodingAuthorized); + Assert.False(profile.RoutingAuthorized); + Assert.Empty(await db.ApiTokens.IgnoreQueryFilters().ToListAsync()); + } } From 3643ae8c609ee4e7f6b388e28cd04f8c398a0a98 Mon Sep 17 00:00:00 2001 From: Stef Kariotidis Date: Sun, 23 Aug 2026 12:40:00 +0300 Subject: [PATCH 09/13] docs(providers): correct settings guide navigation --- Areas/User/Views/LocationProviderSettings/Index.cshtml | 2 +- docs/_sidebar.md | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Areas/User/Views/LocationProviderSettings/Index.cshtml b/Areas/User/Views/LocationProviderSettings/Index.cshtml index caa73108..af4be33f 100644 --- a/Areas/User/Views/LocationProviderSettings/Index.cshtml +++ b/Areas/User/Views/LocationProviderSettings/Index.cshtml @@ -1,7 +1,7 @@ @model Wayfarer.Areas.User.LocationProviderModels.LocationProviderSettingsViewModel

    Personal location providers

    -

    Credentials are protected and never displayed again or sent to WayfarerMobile. Read the credential and usage guide.

    +

    Credentials are protected and never displayed again or sent to WayfarerMobile. Read the credential and usage guide.

    @if (Model.LegacyMigrationState is Wayfarer.Models.LocationProviders.LegacyMapboxMigrationState.Conflict or Wayfarer.Models.LocationProviders.LegacyMapboxMigrationState.ProtectedCredentialUnavailable) {
    Legacy Mapbox migration needs explicit recovery. No provider contact is authorized and no stored value was removed.
    }
    Wayfarer records only its own contacts. Other applications can consume the provider account allowance; use a dedicated Wayfarer key when possible. Multiple keys may still share one provider allowance.
    diff --git a/docs/_sidebar.md b/docs/_sidebar.md index 8b1f659c..ae7ddd72 100644 --- a/docs/_sidebar.md +++ b/docs/_sidebar.md @@ -21,8 +21,8 @@ - [API](18-API.md) - [Database](19-Database.md) - [Deployment](20-Deployment.md) -- [Security](21-Security.md) -- [Personal Location Providers](24-Personal-Location-Providers.md) + - [Security](21-Security.md) + - [Personal Location Providers](24-Personal-Location-Providers.md) - [Testing](22-Testing.md) - [Versioning](23-Versioning.md) From 261d613809682e5de0c0ef28acb282a0a7f0727a Mon Sep 17 00:00:00 2001 From: Stef Kariotidis Date: Sun, 23 Aug 2026 12:41:05 +0300 Subject: [PATCH 10/13] fix(deployment): retain the existing durable key ring --- appsettings.Production.json | 2 +- deployment/deploy.sh | 6 +++--- deployment/install.sh | 6 +++--- deployment/wayfarer.service | 2 +- docs/16-Configuration.md | 2 +- docs/24-Personal-Location-Providers.md | 4 ++-- 6 files changed, 11 insertions(+), 11 deletions(-) diff --git a/appsettings.Production.json b/appsettings.Production.json index 48c802bc..4f28bc6e 100644 --- a/appsettings.Production.json +++ b/appsettings.Production.json @@ -16,7 +16,7 @@ "ContactEmail": "admin@your-domain.example" }, "DataProtection": { - "KeyRingPath": "/var/lib/wayfarer/data-protection-keys" + "KeyRingPath": "/home/wayfarer/.aspnet/DataProtection-Keys" }, "CacheSettings": { "TileCacheDirectory": "/var/www/wayfarer/TileCache", diff --git a/deployment/deploy.sh b/deployment/deploy.sh index 0d88904b..e90c26f0 100644 --- a/deployment/deploy.sh +++ b/deployment/deploy.sh @@ -226,9 +226,9 @@ sudo chown -R "$APP_USER":"$APP_USER" "$DEPLOY_DIR" # Ensure writable directories exist and have correct permissions echo "Ensuring writable directories exist..." sudo mkdir -p "$DEPLOY_DIR/Uploads" "$DEPLOY_DIR/TileCache" "$DEPLOY_DIR/ImageCache" "$DEPLOY_DIR/ChromeCache" "$DEPLOY_DIR/Logs" -sudo mkdir -p /var/lib/wayfarer/data-protection-keys -sudo chown -R "$APP_USER":"$APP_USER" /var/lib/wayfarer -sudo chmod 700 /var/lib/wayfarer /var/lib/wayfarer/data-protection-keys +sudo mkdir -p "/home/$APP_USER/.aspnet/DataProtection-Keys" +sudo chown -R "$APP_USER":"$APP_USER" "/home/$APP_USER/.aspnet" +sudo chmod 700 "/home/$APP_USER/.aspnet" "/home/$APP_USER/.aspnet/DataProtection-Keys" sudo chown -R "$APP_USER":"$APP_USER" "$DEPLOY_DIR/Uploads" "$DEPLOY_DIR/TileCache" "$DEPLOY_DIR/ImageCache" "$DEPLOY_DIR/ChromeCache" "$DEPLOY_DIR/Logs" sudo chmod 755 "$DEPLOY_DIR/Uploads" "$DEPLOY_DIR/TileCache" "$DEPLOY_DIR/ImageCache" "$DEPLOY_DIR/ChromeCache" "$DEPLOY_DIR/Logs" diff --git a/deployment/install.sh b/deployment/install.sh index 6b24751d..bed4a21a 100644 --- a/deployment/install.sh +++ b/deployment/install.sh @@ -441,9 +441,9 @@ echo "" echo "Creating deployment directory (if needed) and setting ownership." sudo mkdir -p "$DEPLOY_DIR" sudo chown -R "$APP_USER":"$APP_USER" "$DEPLOY_DIR" -sudo mkdir -p /var/lib/wayfarer/data-protection-keys -sudo chown -R "$APP_USER":"$APP_USER" /var/lib/wayfarer -sudo chmod 700 /var/lib/wayfarer /var/lib/wayfarer/data-protection-keys +sudo mkdir -p "/home/$APP_USER/.aspnet/DataProtection-Keys" +sudo chown -R "$APP_USER":"$APP_USER" "/home/$APP_USER/.aspnet" +sudo chmod 700 "/home/$APP_USER/.aspnet" "/home/$APP_USER/.aspnet/DataProtection-Keys" # ------------------------------ # 6. Configure PostgreSQL diff --git a/deployment/wayfarer.service b/deployment/wayfarer.service index 155ec294..607e5b31 100644 --- a/deployment/wayfarer.service +++ b/deployment/wayfarer.service @@ -56,7 +56,7 @@ Environment=DOTNET_ENVIRONMENT=Production Environment=DOTNET_PRINT_TELEMETRY_MESSAGE=false # HOME is required for Playwright's Chromium runtime profiles (PDF export) Environment=HOME=/home/wayfarer -Environment=DataProtection__KeyRingPath=/var/lib/wayfarer/data-protection-keys +Environment=DataProtection__KeyRingPath=/home/wayfarer/.aspnet/DataProtection-Keys # TILE PROXY CONTACT EMAIL # Used in the User-Agent header sent to tile providers (e.g. OpenStreetMap). diff --git a/docs/16-Configuration.md b/docs/16-Configuration.md index 9df025b2..da594092 100644 --- a/docs/16-Configuration.md +++ b/docs/16-Configuration.md @@ -81,7 +81,7 @@ Uploads - Upload staging directory defaults under `Uploads/Temp/` (path visible in Admin Settings). Ensure writable by the app. Reverse Geocoding (Per‑User) -- `DataProtection:KeyRingPath` is the persistent key authority for Identity and protected administrator/personal provider credentials. The supported systemd deployment uses `/var/lib/wayfarer/data-protection-keys`; backup and migration requirements are in [Personal Location Providers](24-Personal-Location-Providers.md). +- `DataProtection:KeyRingPath` is the persistent key authority for Identity and protected administrator/personal provider credentials. The supported systemd deployment explicitly retains its existing `/home/wayfarer/.aspnet/DataProtection-Keys` authority; backup requirements are in [Personal Location Providers](24-Personal-Location-Providers.md). - `LocationProviders:Geoapify:RollingCreditLimit` defaults to 2,500 credits. `LocationProviders:Mapbox:PermanentGeocodingLimit` and `LocationProviders:Mapbox:DirectionsLimit` configure separate Wayfarer safety counters. Users manage explicit authorization and selection in Personal location providers. Mobile diff --git a/docs/24-Personal-Location-Providers.md b/docs/24-Personal-Location-Providers.md index 00db3e81..0972c8cf 100644 --- a/docs/24-Personal-Location-Providers.md +++ b/docs/24-Personal-Location-Providers.md @@ -4,9 +4,9 @@ Wayfarer stores one personal credential per user and provider (`Geoapify` or `Ma ## Key-ring durability and backup -The supported Linux/systemd deployment sets `DataProtection__KeyRingPath=/var/lib/wayfarer/data-protection-keys`. The installer/deployer creates that directory as the `wayfarer` service identity with mode `0700`. It survives process restarts and `/var/www/wayfarer` publish replacement. Keys are scoped to the application name `Wayfarer`; at-rest protection is the dedicated service identity plus host filesystem permissions and disk/host encryption. Wayfarer does not claim certificate, cloud-KMS, container, or multi-host key sharing. +The supported Linux/systemd deployment already pins `HOME=/home/wayfarer`; its existing ASP.NET Core ring is `/home/wayfarer/.aspnet/DataProtection-Keys`. Wayfarer now configures that same path explicitly, and the installer/deployer enforces service ownership with mode `0700`. It survives process restarts and `/var/www/wayfarer` publish replacement without relocating existing keys. Keys are scoped to the application name `Wayfarer`; at-rest protection is the dedicated service identity plus host filesystem permissions and disk/host encryption. Wayfarer does not claim certificate, cloud-KMS, container, or multi-host key sharing. -Back up the key-ring directory together with the PostgreSQL database and restore both from the same recovery set. Losing applicable keys makes protected credentials unreadable. Startup fails closed if the directory is unusable or any retained administrator/personal routing or location-provider credential cannot be read. Before changing an existing deployment to the explicit path, stop Wayfarer and copy the existing service-user ring from `/home/wayfarer/.aspnet/DataProtection-Keys` if it exists; retain the original backup until startup and credential readback succeed. +Back up the key-ring directory together with the PostgreSQL database and restore both from the same recovery set. Losing applicable keys makes protected credentials unreadable. Startup fails closed if the directory is unusable or any retained administrator/personal routing or location-provider credential cannot be read. ## Profiles, authorization, and switching From 4d616e3b795e50849de12866f2ed6f313a748c86 Mon Sep 17 00:00:00 2001 From: Stef Kariotidis Date: Sun, 23 Aug 2026 15:08:13 +0300 Subject: [PATCH 11/13] WIP: cover provider credential upgrade corrections (checkpoint; tests failing) --- .../PersonalProviderUsagePostgresTests.cs | 70 ++++++++++ ...roviderCredentialUpgradeCorrectionTests.cs | 132 ++++++++++++++++++ 2 files changed, 202 insertions(+) create mode 100644 tests/Wayfarer.Tests/Services/ProviderCredentialUpgradeCorrectionTests.cs diff --git a/tests/Wayfarer.Tests/Models/PersonalProviderUsagePostgresTests.cs b/tests/Wayfarer.Tests/Models/PersonalProviderUsagePostgresTests.cs index 6f8c5cb0..7fff70d9 100644 --- a/tests/Wayfarer.Tests/Models/PersonalProviderUsagePostgresTests.cs +++ b/tests/Wayfarer.Tests/Models/PersonalProviderUsagePostgresTests.cs @@ -1,6 +1,7 @@ using Microsoft.AspNetCore.DataProtection; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Configuration; +using Wayfarer.Models; using Wayfarer.Models.LocationProviders; using Wayfarer.Services.LocationProviders; using Wayfarer.Tests.Infrastructure; @@ -12,6 +13,75 @@ namespace Wayfarer.Tests.Models; [Collection(PostgresEnvironmentEvidenceTestCollection.Name)] public sealed class PersonalProviderUsagePostgresTests(PostgresImportTestFixture fixture) { + [PostgresFact] + public async Task LegacyMapboxMigration_BypassesProductionFilterAndPreservesUnrelatedData() + { + fixture.RequireAvailable(); + var user = await fixture.CreateUserAsync(); + var retainedTag = new Tag { Id = Guid.NewGuid(), Name = "Retained domain data", Slug = $"retained-{Guid.NewGuid():N}" }; + await using (var setup = fixture.CreateContext()) + { + var trackedUser = await setup.Users.SingleAsync(item => item.Id == user.Id); + setup.Add(retainedTag); + setup.ApiTokens.AddRange( + new ApiToken { Name = " MapBOX ", Token = "legacy-mapbox-key", UserId = user.Id, User = trackedUser }, + new ApiToken { Name = "mobile", Token = "retained-token", UserId = user.Id, User = trackedUser }, + new ApiToken { Name = "MyMapboxBackup", Token = "retained-substring", UserId = user.Id, User = trackedUser }); + await setup.SaveChangesAsync(); + Assert.DoesNotContain(await setup.ApiTokens.ToListAsync(), token => + string.Equals(token.Name.Trim(), "Mapbox", StringComparison.OrdinalIgnoreCase)); + } + fixture.RegisterTag(retainedTag); + + var protection = new EphemeralDataProtectionProvider(); + await using (var migrate = fixture.CreateContext()) + { + var owner = new PersonalProviderCredentialService(protection); + var result = await new LegacyMapboxMigrationService(migrate, owner).MigrateAsync(user.Id); + var profile = await migrate.PersonalLocationProviderProfiles.SingleAsync(item => item.UserId == user.Id); + + Assert.True(result.ProtectedCredentialReady); + Assert.Equal("legacy-mapbox-key", owner.Read(profile).Credential); + Assert.True(profile.GeocodingAuthorized); + Assert.False(profile.RoutingAuthorized); + } + + await using var verify = fixture.CreateContext(); + var remaining = await verify.ApiTokens.IgnoreQueryFilters().Where(token => token.UserId == user.Id).ToListAsync(); + Assert.Equal(2, remaining.Count); + Assert.DoesNotContain(remaining, token => + string.Equals(token.Name.Trim(), "Mapbox", StringComparison.OrdinalIgnoreCase)); + Assert.Contains(remaining, token => token.Name == "mobile"); + Assert.Contains(remaining, token => token.Name == "MyMapboxBackup"); + Assert.True(await verify.Tags.AnyAsync(tag => tag.Id == retainedTag.Id)); + } + + [PostgresFact] + public async Task LegacyMapboxMigration_DistinctAliasesPreserveConflictRows() + { + fixture.RequireAvailable(); + var user = await fixture.CreateUserAsync(); + await using (var setup = fixture.CreateContext()) + { + var trackedUser = await setup.Users.SingleAsync(item => item.Id == user.Id); + setup.ApiTokens.AddRange( + new ApiToken { Name = "Mapbox", Token = "first-value", UserId = user.Id, User = trackedUser }, + new ApiToken { Name = " mapBOX ", Token = "second-value", UserId = user.Id, User = trackedUser }); + await setup.SaveChangesAsync(); + } + + await using (var migrate = fixture.CreateContext()) + { + var owner = new PersonalProviderCredentialService(new EphemeralDataProtectionProvider()); + var result = await new LegacyMapboxMigrationService(migrate, owner).MigrateAsync(user.Id); + Assert.Equal(LegacyMapboxMigrationState.Conflict, result.State); + Assert.False(result.ProtectedCredentialReady); + } + + await using var verify = fixture.CreateContext(); + Assert.Equal(2, await verify.ApiTokens.IgnoreQueryFilters().CountAsync(token => token.UserId == user.Id)); + } + [PostgresFact] public async Task GeoapifyConcurrentLastCredit_HasExactlyOneWinnerAcrossContexts() { diff --git a/tests/Wayfarer.Tests/Services/ProviderCredentialUpgradeCorrectionTests.cs b/tests/Wayfarer.Tests/Services/ProviderCredentialUpgradeCorrectionTests.cs new file mode 100644 index 00000000..da91e6cf --- /dev/null +++ b/tests/Wayfarer.Tests/Services/ProviderCredentialUpgradeCorrectionTests.cs @@ -0,0 +1,132 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.DataProtection; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using Wayfarer.Models; +using Wayfarer.Services.ExternalRouting; +using Wayfarer.Services.LocationProviders; +using Xunit; + +namespace Wayfarer.Tests.Services; + +/// Protects the credential and deployment contracts required for a safe #499 upgrade. +public sealed class ProviderCredentialUpgradeCorrectionTests +{ + [Fact] + public void AdministratorRoutingCiphertext_FromPre499Registration_RemainsReadableAfterUpgrade() => + WithRecreatedProviders(historical => + { + var configuration = new RoutingProviderConfiguration(); + new RoutingProviderCredentialService(historical).Replace(configuration, "historical-admin-key"); + return configuration; + }, (configuration, current) => + { + var read = new RoutingProviderCredentialService(current).Read(configuration); + + Assert.True(read.Succeeded); + Assert.Equal("historical-admin-key", read.Credential); + Assert.False(new UserRoutingCredentialService(current).Unprotect( + "wrong-user", Guid.NewGuid(), configuration.CredentialCiphertext).Succeeded); + }); + + [Fact] + public void PersonalRoutingCiphertext_FromPre499Registration_RemainsReadableAfterUpgrade() => + WithRecreatedProviders(historical => + { + var userId = "historical-routing-user"; + var providerId = Guid.NewGuid(); + var ciphertext = new UserRoutingCredentialService(historical) + .Protect(userId, providerId, "historical-personal-key"); + return (userId, providerId, ciphertext); + }, (protectedValue, current) => + { + var currentOwner = new UserRoutingCredentialService(current); + Assert.Equal("historical-personal-key", + currentOwner.Unprotect(protectedValue.userId, protectedValue.providerId, protectedValue.ciphertext).Credential); + Assert.False(currentOwner.Unprotect("wrong-user", protectedValue.providerId, protectedValue.ciphertext).Succeeded); + Assert.False(currentOwner.Unprotect(protectedValue.userId, Guid.NewGuid(), protectedValue.ciphertext).Succeeded); + }); + + [Fact] + public void DeploymentGuidance_UsesRetainedRingAndRestoresOwnershipAndPermissions() + { + var root = FindRepositoryRoot(); + var deployment = File.ReadAllText(Path.Combine(root, "docs", "20-Deployment.md")); + var providers = File.ReadAllText(Path.Combine(root, "docs", "24-Personal-Location-Providers.md")); + var currentFiles = Directory.EnumerateFiles(Path.Combine(root, "docs"), "*.md") + .Concat(Directory.EnumerateFiles(Path.Combine(root, "deployment"), "*")) + .Where(path => Path.GetExtension(path) is ".md" or ".sh" or ".service") + .Append(Path.Combine(root, "appsettings.Production.json")) + .ToArray(); + + Assert.All(currentFiles, path => Assert.DoesNotContain( + "/var/lib/wayfarer/data-protection-keys", File.ReadAllText(path), StringComparison.Ordinal)); + Assert.Contains("/home/wayfarer/.aspnet/DataProtection-Keys", deployment, StringComparison.Ordinal); + Assert.Contains("/home/wayfarer/.aspnet/DataProtection-Keys", providers, StringComparison.Ordinal); + Assert.Contains("sudo chown -R wayfarer:wayfarer /home/wayfarer/.aspnet/DataProtection-Keys", providers, StringComparison.Ordinal); + Assert.Contains("sudo chmod 700 /home/wayfarer/.aspnet/DataProtection-Keys", providers, StringComparison.Ordinal); + Assert.Contains("before starting", providers, StringComparison.OrdinalIgnoreCase); + Assert.Contains("database and key ring", providers, StringComparison.OrdinalIgnoreCase); + Assert.Contains("/var/www/wayfarer", providers, StringComparison.Ordinal); + Assert.Contains("containers", providers, StringComparison.OrdinalIgnoreCase); + Assert.Contains("multiple hosts", providers, StringComparison.OrdinalIgnoreCase); + Assert.Contains("unreadable", providers, StringComparison.OrdinalIgnoreCase); + } + + private static void WithRecreatedProviders( + Func protect, Action assertion) + { + var root = Path.Combine(Path.GetTempPath(), $"wayfarer-pre499-content-{Guid.NewGuid():N}"); + var ring = Path.Combine(root, "keys"); + Directory.CreateDirectory(ring); + try + { + T protectedValue; + string historicalDiscriminator; + using (var historicalServices = BuildProvider(root, ring, useFinalRegistration: false)) + { + historicalDiscriminator = historicalServices.GetRequiredService>() + .Value.ApplicationDiscriminator + ?? throw new InvalidOperationException("The hosted historical discriminator was not configured."); + protectedValue = protect(historicalServices.GetRequiredService()); + } + using var currentServices = BuildProvider(root, ring, useFinalRegistration: true); + Assert.Equal(historicalDiscriminator, currentServices.GetRequiredService>() + .Value.ApplicationDiscriminator); + assertion(protectedValue, currentServices.GetRequiredService()); + } + finally + { + Directory.Delete(root, recursive: true); + } + } + + private static ServiceProvider BuildProvider(string contentRoot, string ring, bool useFinalRegistration) + { + var builder = WebApplication.CreateBuilder(new WebApplicationOptions + { + ApplicationName = typeof(Program).Assembly.GetName().Name, + ContentRootPath = contentRoot, + EnvironmentName = "Production" + }); + if (useFinalRegistration) + { + builder.Configuration["DataProtection:KeyRingPath"] = ring; + builder.AddWayfarerDataProtection(); + } + else + { + // Pre-#499 relied on ASP.NET Core's host/content-root discriminator without an application name override. + builder.Services.AddDataProtection().PersistKeysToFileSystem(new DirectoryInfo(ring)); + } + return builder.Services.BuildServiceProvider(); + } + + private static string FindRepositoryRoot() + { + var directory = new DirectoryInfo(AppContext.BaseDirectory); + while (directory != null && !File.Exists(Path.Combine(directory.FullName, "Wayfarer.csproj"))) + directory = directory.Parent; + return directory?.FullName ?? throw new DirectoryNotFoundException("Repository root not found."); + } +} From 5ef2299884785237c39f8af601b4c1c1ca3ac2f3 Mon Sep 17 00:00:00 2001 From: Stef Kariotidis Date: Sun, 23 Aug 2026 15:09:47 +0300 Subject: [PATCH 12/13] fix(security): preserve provider credential upgrade compatibility --- Services/LocationProviders/DataProtectionAuthority.cs | 1 - Services/LocationProviders/LegacyMapboxMigrationService.cs | 2 +- .../Services/PersonalLocationProviderFoundationTests.cs | 4 ++-- 3 files changed, 3 insertions(+), 4 deletions(-) diff --git a/Services/LocationProviders/DataProtectionAuthority.cs b/Services/LocationProviders/DataProtectionAuthority.cs index f6d6ffd6..2c958ff2 100644 --- a/Services/LocationProviders/DataProtectionAuthority.cs +++ b/Services/LocationProviders/DataProtectionAuthority.cs @@ -20,7 +20,6 @@ public static void AddWayfarerDataProtection(this WebApplicationBuilder builder) : Path.GetFullPath(configured); Directory.CreateDirectory(path); builder.Services.AddDataProtection() - .SetApplicationName("Wayfarer") .PersistKeysToFileSystem(new DirectoryInfo(path)); builder.Services.AddSingleton(new DataProtectionKeyRing(path)); } diff --git a/Services/LocationProviders/LegacyMapboxMigrationService.cs b/Services/LocationProviders/LegacyMapboxMigrationService.cs index 1f5647ad..0c76ac80 100644 --- a/Services/LocationProviders/LegacyMapboxMigrationService.cs +++ b/Services/LocationProviders/LegacyMapboxMigrationService.cs @@ -106,7 +106,7 @@ private async Task> LockLegacyRowsAsync(string userId, Cancellati ? await dbContext.ApiTokens.FromSqlInterpolated($$""" SELECT * FROM "ApiTokens" WHERE "UserId" = {{userId}} AND lower(btrim("Name")) = 'mapbox' AND btrim(COALESCE("Token", '')) <> '' FOR UPDATE - """).ToListAsync(cancellationToken) + """).IgnoreQueryFilters().ToListAsync(cancellationToken) : await dbContext.ApiTokens.IgnoreQueryFilters().Where(item => item.UserId == userId && item.Token != null) .ToListAsync(cancellationToken); return rows.Where(item => PersonalProviderKeys.IsLegacyMapbox(item.Name) diff --git a/tests/Wayfarer.Tests/Services/PersonalLocationProviderFoundationTests.cs b/tests/Wayfarer.Tests/Services/PersonalLocationProviderFoundationTests.cs index 31f1516b..dbf1e2f6 100644 --- a/tests/Wayfarer.Tests/Services/PersonalLocationProviderFoundationTests.cs +++ b/tests/Wayfarer.Tests/Services/PersonalLocationProviderFoundationTests.cs @@ -134,11 +134,11 @@ public void PersistentKeyRing_ReadsCredentialAfterServiceRecreation() { var profile = PersonalLocationProviderProfile.Create("restart-user", PersonalLocationProvider.Geoapify); var first = new PersonalProviderCredentialService(DataProtectionProvider.Create( - new DirectoryInfo(path), options => options.SetApplicationName("Wayfarer"))); + new DirectoryInfo(path))); first.Replace(profile, "restart-safe-key"); var recreated = new PersonalProviderCredentialService(DataProtectionProvider.Create( - new DirectoryInfo(path), options => options.SetApplicationName("Wayfarer"))); + new DirectoryInfo(path))); Assert.Equal("restart-safe-key", recreated.Read(profile).Credential); } From cc908977d2a68e4dd11df67dc2c207978b8a4bfa Mon Sep 17 00:00:00 2001 From: Stef Kariotidis Date: Sun, 23 Aug 2026 15:10:22 +0300 Subject: [PATCH 13/13] docs(security): align Data Protection recovery guidance --- docs/20-Deployment.md | 2 +- docs/24-Personal-Location-Providers.md | 15 +++++++++++++-- 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/docs/20-Deployment.md b/docs/20-Deployment.md index fd6e64ff..a30e3ca3 100644 --- a/docs/20-Deployment.md +++ b/docs/20-Deployment.md @@ -405,7 +405,7 @@ sudo systemctl start wayfarer ## Updating Wayfarer -Before the first release that uses protected personal provider profiles, preserve the service user's existing Data Protection keys and configure `/var/lib/wayfarer/data-protection-keys` as described in [Personal Location Providers](24-Personal-Location-Providers.md). Database-only backups are incomplete once protected credentials exist. +Before the first release that uses protected personal provider profiles, preserve the service user's existing Data Protection keys at `/home/wayfarer/.aspnet/DataProtection-Keys` as described in [Personal Location Providers](24-Personal-Location-Providers.md). Back up and restore the PostgreSQL database and key ring together; restoration, ownership, and permission recovery must be complete before starting Wayfarer. Database-only backups are incomplete once protected credentials exist. ### Automated (Recommended) diff --git a/docs/24-Personal-Location-Providers.md b/docs/24-Personal-Location-Providers.md index 0972c8cf..2ee1802d 100644 --- a/docs/24-Personal-Location-Providers.md +++ b/docs/24-Personal-Location-Providers.md @@ -4,9 +4,20 @@ Wayfarer stores one personal credential per user and provider (`Geoapify` or `Ma ## Key-ring durability and backup -The supported Linux/systemd deployment already pins `HOME=/home/wayfarer`; its existing ASP.NET Core ring is `/home/wayfarer/.aspnet/DataProtection-Keys`. Wayfarer now configures that same path explicitly, and the installer/deployer enforces service ownership with mode `0700`. It survives process restarts and `/var/www/wayfarer` publish replacement without relocating existing keys. Keys are scoped to the application name `Wayfarer`; at-rest protection is the dedicated service identity plus host filesystem permissions and disk/host encryption. Wayfarer does not claim certificate, cloud-KMS, container, or multi-host key sharing. +The supported Linux/systemd deployment pins `WorkingDirectory=/var/www/wayfarer` and `HOME=/home/wayfarer`. Its existing ASP.NET Core ring is `/home/wayfarer/.aspnet/DataProtection-Keys`; Wayfarer configures that retained path explicitly. The application discriminator remains the ASP.NET Core hosted discriminator derived from the fixed `/var/www/wayfarer` content root, preserving ciphertext created before #499. The ring survives process restarts and publish replacement without relocating existing keys. -Back up the key-ring directory together with the PostgreSQL database and restore both from the same recovery set. Losing applicable keys makes protected credentials unreadable. Startup fails closed if the directory is unusable or any retained administrator/personal routing or location-provider credential cannot be read. +The installer and deployer assign the ring to the `wayfarer` service account and set the ring directory to mode `0700`. They do not apply a separate mode to individual key files; the directory boundary prevents access by other accounts. At-rest protection is the dedicated service identity plus host filesystem permissions and disk/host encryption. + +Back up the PostgreSQL database and key ring together in the same recovery set. Restore both before starting the application, then restore the production ownership and directory permission exactly: + +```bash +sudo chown -R wayfarer:wayfarer /home/wayfarer/.aspnet/DataProtection-Keys +sudo chmod 700 /home/wayfarer/.aspnet/DataProtection-Keys +``` + +Losing the applicable key ring makes protected administrator routing, personal routing, and location-provider credentials unreadable even when the database survives. Startup fails closed if the directory is unusable or retained protected credentials cannot be read. + +This compatibility contract covers the fixed single-host systemd deployment at `/var/www/wayfarer`. Containers, a changed content root, and multiple hosts are not covered automatically and require an explicitly shared, stable Data Protection authority before deployment; Wayfarer does not claim certificate, cloud-KMS, container, or multi-host key sharing. ## Profiles, authorization, and switching