feat: allow max stores per user (#7320)
What changed, and why it matters
This commit adds a new feature that lets server administrators set a maximum number of stores each non-admin user can create, both globally and per-user. It is a new restriction/control feature, not a fix for an existing vulnerability. There is no evidence in the commit or supplied references that this change addresses a security incident or was disclosed as security-relevant.
Review as a normal feature addition. Verify the SQL CTE correctly handles concurrency (the conditional insert appears atomic within the statement, but confirm transaction/locking behavior for your database). Ensure the Greenfield API and MVC UI pre-checks stay consistent with the repository-level enforcement. No urgent security patch action is indicated by the commit itself.
Security signals we found
New authorization/enforcement logic added (store creation quota)
Server admins explicitly exempted from quota
SQL CTE performs conditional insert based on role and count
Input validation added for StoreQuota (range 0 to int.MaxValue, non-negative)
No mention of CVE, vulnerability, bug bounty, or security fix in commit message or diff
Evidence from the diff
The change introduces a StoreQuota setting in PoliciesSettings and a per-user StoreQuota override stored in the user’s blob. Store creation now checks the effective limit (per-user override, then global default, with server admins exempt) and returns a QuotaExceeded result if the user already owns enough stores. The check is implemented in StoreRepository.CreateStore via a single SQL CTE that reads the user blob, global settings, admin role, and current store count, then conditionally inserts into Stores and UserStore. UI and Greenfield API controllers surface the limit with error messages, and tests verify the behavior.
Changed components
BTCPayServer/Controllers/GreenField/GreenfieldStoresController.csBTCPayServer/Controllers/UIUserStoresController.csBTCPayServer/Controllers/UIServerController.Users.csBTCPayServer/Services/Stores/StoreRepository.csBTCPayServer/Services/PoliciesSettings.csBTCPayServer/Services/UserService.csBTCPayServer.Data/Data/ApplicationUser.csBTCPayServer.Client/Models/ApplicationUserData.csInspect captured patch +280 / −26
diff --git a/BTCPayServer.Client/Models/ApplicationUserData.cs b/BTCPayServer.Client/Models/ApplicationUserData.cs
index d9eec2c..f1d3164 100644
--- a/BTCPayServer.Client/Models/ApplicationUserData.cs
+++ b/BTCPayServer.Client/Models/ApplicationUserData.cs
@@ -65,6 +65,11 @@ namespace BTCPayServer.Client.Models
public bool Disabled { get; set; }
+ /// <summary>
+ /// per-user override for the max number of stores this user can create. Null means the server default applies.
+ /// </summary>
+ public int? StoreQuota { get; set; }
+
[JsonExtensionData]
public IDictionary<string, JToken> AdditionalData { get; set; } = new Dictionary<string, JToken>();
}
diff --git a/BTCPayServer.Client/Models/UpdateApplicationUserRequest.cs b/BTCPayServer.Client/Models/UpdateApplicationUserRequest.cs
index cb0b1b3..0a48b7e 100644
--- a/BTCPayServer.Client/Models/UpdateApplicationUserRequest.cs
+++ b/BTCPayServer.Client/Models/UpdateApplicationUserRequest.cs
@@ -27,3 +27,4 @@ public class UpdateApplicationUserRequest
/// </summary>
public string NewPassword { get; set; }
}
+
diff --git a/BTCPayServer.Data/Data/ApplicationUser.cs b/BTCPayServer.Data/Data/ApplicationUser.cs
index 27ca694..a065364 100644
--- a/BTCPayServer.Data/Data/ApplicationUser.cs
+++ b/BTCPayServer.Data/Data/ApplicationUser.cs
@@ -53,5 +53,6 @@ namespace BTCPayServer.Data
public string ImageUrl { get; set; }
public string Name { get; set; }
public string InvitationToken { get; set; }
+ public int? StoreQuota { get; set; }
}
}
diff --git a/BTCPayServer.Tests/GreenfieldAPITests.cs b/BTCPayServer.Tests/GreenfieldAPITests.cs
index fe33b99..a8e7f4e 100644
--- a/BTCPayServer.Tests/GreenfieldAPITests.cs
+++ b/BTCPayServer.Tests/GreenfieldAPITests.cs
@@ -28,8 +28,10 @@ using BTCPayServer.Services.Notifications;
using BTCPayServer.Services.Notifications.Blobs;
using BTCPayServer.Services.Stores;
using Dapper;
+using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using NBitcoin;
using NBitpayClient;
@@ -3276,6 +3278,81 @@ namespace BTCPayServer.Tests
Assert.Equal("Unapproving user failed: No approval required", err.APIError.Message);
}
+ [Fact(Timeout = 60 * 2 * 1000)]
+ [Trait("Integration", "Integration")]
+ public async Task StoreQuotaTests()
+ {
+ using var tester = CreateServerTester(newDb: true);
+ await tester.StartAsync();
+
+ var admin = tester.NewAccount();
+ await admin.GrantAccessAsync(true);
+ var adminClient = await admin.CreateClient(Policies.Unrestricted);
+
+ var user = tester.NewAccount();
+ await user.GrantAccessAsync();
+ var userClient = await user.CreateClient(Policies.Unrestricted);
+
+ var settings = tester.PayTester.GetService<SettingsRepository>();
+
+ // no limit by default
+ var s1 = await userClient.CreateStore(new CreateStoreRequest { Name = "Store 1" });
+ Assert.NotNull(s1.Id);
+
+ // set global limit to 1, user already has 2
+ await settings.UpdateSetting(new PoliciesSettings { StoreQuota = 1 });
+ await AssertAPIError("store-limit-reached", () =>
+ userClient.CreateStore(new CreateStoreRequest { Name = "Store 2" }));
+
+ // admin is never blocked
+ var adminStore = await adminClient.CreateStore(new CreateStoreRequest { Name = "Admin store" });
+ Assert.NotNull(adminStore.Id);
+
+ // raise global limit to 3
+ await settings.UpdateSetting(new PoliciesSettings { StoreQuota = 3 });
+ var s2 = await userClient.CreateStore(new CreateStoreRequest { Name = "Store 2" });
+ Assert.NotNull(s2.Id);
+
+ // at the new limit again
+ await AssertAPIError("store-limit-reached", () =>
+ userClient.CreateStore(new CreateStoreRequest { Name = "Store 3" }));
+
+ // remove global limit
+ await settings.UpdateSetting(new PoliciesSettings { StoreQuota = null });
+ var s4 = await userClient.CreateStore(new CreateStoreRequest { Name = "Store 4" });
+ Assert.NotNull(s4.Id);
+
+ // global limit of 0 blocks all non-admins
+ await settings.UpdateSetting(new PoliciesSettings { StoreQuota = 0 });
+ await AssertAPIError("store-limit-reached", () =>
+ userClient.CreateStore(new CreateStoreRequest { Name = "Store 5" }));
+
+ // set per-user quota of 5 directly in DB
+ await settings.UpdateSetting(new PoliciesSettings { StoreQuota = null });
+ using var scope = tester.PayTester.ServiceProvider.CreateScope();
+ var userManager = scope.ServiceProvider.GetRequiredService<UserManager<ApplicationUser>>();
+ var appUser = await userManager.FindByIdAsync(user.UserId);
+ var blob = appUser.GetBlob() ?? new();
+ blob.StoreQuota = 5;
+ appUser.SetBlob(blob);
+ await userManager.UpdateAsync(appUser);
+
+ // user can create one more store to reach quota
+ var s5 = await userClient.CreateStore(new CreateStoreRequest { Name = "Store 5" });
+ Assert.NotNull(s5.Id);
+
+ // at personal quota, blocked even though global limit is null
+ await AssertAPIError("store-limit-reached", () =>
+ userClient.CreateStore(new CreateStoreRequest { Name = "Store 6" }));
+
+ // remove per-user quota, falls back to global limit
+ blob.StoreQuota = null;
+ appUser.SetBlob(blob);
+ await userManager.UpdateAsync(appUser);
+ var s6 = await userClient.CreateStore(new CreateStoreRequest { Name = "Store 6" });
+ Assert.NotNull(s6.Id);
+ }
+
[Fact(Timeout = 60 * 2 * 1000)]
[Trait("Integration", "Integration")]
[Trait("Lightning", "Lightning")]
diff --git a/BTCPayServer.Tests/PayJoinTests.cs b/BTCPayServer.Tests/PayJoinTests.cs
index 12fcb1a..4a180b4 100644
--- a/BTCPayServer.Tests/PayJoinTests.cs
+++ b/BTCPayServer.Tests/PayJoinTests.cs
@@ -239,7 +239,7 @@ namespace BTCPayServer.Tests
}
}
- [Fact]
+ [Fact(Timeout = 30_000)]
[Trait("Playwright", "Playwright-2")]
public async Task CanUsePayjoinForTopUp()
{
diff --git a/BTCPayServer/Controllers/GreenField/GreenfieldStoresController.cs b/BTCPayServer/Controllers/GreenField/GreenfieldStoresController.cs
index aa17e97..c692621 100644
--- a/BTCPayServer/Controllers/GreenField/GreenfieldStoresController.cs
+++ b/BTCPayServer/Controllers/GreenField/GreenfieldStoresController.cs
@@ -11,6 +11,7 @@ using BTCPayServer.Client;
using BTCPayServer.Client.Models;
using BTCPayServer.Data;
using BTCPayServer.Payments;
+using BTCPayServer.Security;
using BTCPayServer.Services;
using BTCPayServer.Services.Rates;
using BTCPayServer.Services.Stores;
@@ -37,6 +38,8 @@ namespace BTCPayServer.Controllers.Greenfield
private readonly IFileService _fileService;
private readonly UriResolver _uriResolver;
private readonly JsonSerializerSettings _serializedSettings;
+ private readonly PoliciesSettings _policiesSettings;
+ private readonly IAuthorizationService _authorizationService;
public GreenfieldStoresController(
StoreRepository storeRepository,
@@ -44,7 +47,9 @@ namespace BTCPayServer.Controllers.Greenfield
UserManager<ApplicationUser> userManager,
IFileService fileService,
IOptions<MvcNewtonsoftJsonOptions> jsonOptions,
- UriResolver uriResolver)
+ UriResolver uriResolver,
+ PoliciesSettings policiesSettings,
+ IAuthorizationService authorizationService)
{
_storeRepository = storeRepository;
_currencyNameTable = currencyNameTable;
@@ -52,6 +57,8 @@ namespace BTCPayServer.Controllers.Greenfield
_fileService = fileService;
_uriResolver = uriResolver;
_serializedSettings = jsonOptions.Value.SerializerSettings;
+ _policiesSettings = policiesSettings;
+ _authorizationService = authorizationService;
}
[Authorize(Policy = Policies.CanViewStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Greenfield)]
@@ -89,7 +96,13 @@ namespace BTCPayServer.Controllers.Greenfield
var validationResult = Validate(request);
if (validationResult != null) return validationResult;
ToModel(request, store);
- await _storeRepository.CreateStore(User.GetId(), store);
+
+ var result = await _storeRepository.CreateStore(User.GetId(), store);
+ if (result == StoreRepository.CreateStoreResult.QuotaExceeded)
+ {
+ return this.CreateAPIError(403, "store-limit-reached",
+ $"You have reached the maximum number of stores allowed.");
+ }
return Ok(await FromModel(store));
}
diff --git a/BTCPayServer/Controllers/UIServerController.Users.cs b/BTCPayServer/Controllers/UIServerController.Users.cs
index 72bbc42..d33d213 100644
--- a/BTCPayServer/Controllers/UIServerController.Users.cs
+++ b/BTCPayServer/Controllers/UIServerController.Users.cs
@@ -103,7 +103,8 @@ namespace BTCPayServer.Controllers
ImageUrl = string.IsNullOrEmpty(blob?.ImageUrl) ? null : await _uriResolver.Resolve(Request.GetAbsoluteRootUri(), UnresolvedUri.Create(blob.ImageUrl)),
EmailConfirmed = user.RequiresEmailConfirmation ? user.EmailConfirmed : null,
Approved = user.RequiresApproval ? user.Approved : null,
- IsAdmin = Roles.HasServerAdmin(roles)
+ IsAdmin = Roles.HasServerAdmin(roles),
+ StoreQuota = blob?.StoreQuota
};
return View(model);
}
@@ -137,6 +138,17 @@ namespace BTCPayServer.Controllers
propertiesChanged = true;
}
+ if (blob.StoreQuota != viewModel.StoreQuota)
+ {
+ if (viewModel.StoreQuota is < 0)
+ {
+ ModelState.AddModelError(nameof(viewModel.StoreQuota), StringLocalizer["Store quota must be 0 or greater."].Value);
+ return View(viewModel);
+ }
+ blob.StoreQuota = viewModel.StoreQuota;
+ propertiesChanged = true;
+ }
+
if (viewModel.ImageFile != null)
{
var imageUpload = await _fileService.UploadImage(viewModel.ImageFile, user.Id);
diff --git a/BTCPayServer/Controllers/UIUserStoresController.cs b/BTCPayServer/Controllers/UIUserStoresController.cs
index 1a71346..3e03312 100644
--- a/BTCPayServer/Controllers/UIUserStoresController.cs
+++ b/BTCPayServer/Controllers/UIUserStoresController.cs
@@ -7,9 +7,11 @@ using BTCPayServer.Abstractions.Models;
using BTCPayServer.Client;
using BTCPayServer.Data;
using BTCPayServer.Models.StoreViewModels;
+using BTCPayServer.Services;
using BTCPayServer.Services.Rates;
using BTCPayServer.Services.Stores;
using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Rendering;
using Microsoft.Extensions.Localization;
@@ -23,18 +25,24 @@ namespace BTCPayServer.Controllers
private readonly IStringLocalizer StringLocalizer;
private readonly DefaultRulesCollection _defaultRules;
private readonly RateFetcher _rateFactory;
+ private readonly PoliciesSettings _policiesSettings;
+ private readonly UserManager<ApplicationUser> _userManager;
public string CreatedStoreId { get; set; }
public UIUserStoresController(
DefaultRulesCollection defaultRules,
StoreRepository storeRepository,
IStringLocalizer stringLocalizer,
- RateFetcher rateFactory)
+ RateFetcher rateFactory,
+ PoliciesSettings policiesSettings,
+ UserManager<ApplicationUser> userManager)
{
_repo = storeRepository;
StringLocalizer = stringLocalizer;
_defaultRules = defaultRules;
_rateFactory = rateFactory;
+ _policiesSettings = policiesSettings;
+ _userManager = userManager;
}
[HttpGet]
@@ -62,7 +70,25 @@ namespace BTCPayServer.Controllers
[Authorize(AuthenticationSchemes = AuthenticationSchemes.Cookie, Policy = Policies.CanModifyStoreSettingsUnscoped)]
public async Task<IActionResult> CreateStore(bool skipWizard)
{
- var stores = await _repo.GetStoresByUserId(User.GetId());
+ var userId = User.GetId();
+ var limit = await GetEffectiveStoreLimitAsync();
+ if (limit.HasValue)
+ {
+ var count = await _repo.CountStoresByUserId(userId);
+ if (count >= limit.Value)
+ {
+ TempData.SetStatusMessageModel(new StatusMessageModel
+ {
+ Severity = StatusMessageModel.StatusSeverity.Error,
+ Message = limit.Value == 0
+ ? StringLocalizer["Store creation is not allowed on this server."].Value
+ : StringLocalizer["You have reached the maximum number of stores allowed ({0}).", limit.Value].Value
+ });
+ return RedirectToAction(nameof(ListStores));
+ }
+ }
+
+ var stores = await _repo.GetStoresByUserId(userId);
var defaultTemplate = await _repo.GetDefaultStoreTemplate();
var blob = defaultTemplate.GetStoreBlob();
var vm = new CreateStoreViewModel
@@ -82,9 +108,11 @@ namespace BTCPayServer.Controllers
[Authorize(AuthenticationSchemes = AuthenticationSchemes.Cookie, Policy = Policies.CanModifyStoreSettingsUnscoped)]
public async Task<IActionResult> CreateStore(CreateStoreViewModel vm)
{
+ var userId = User.GetId();
+
if (!ModelState.IsValid)
{
- var stores = await _repo.GetStoresByUserId(User.GetId());
+ var stores = await _repo.GetStoresByUserId(userId);
vm.IsFirstStore = !stores.Any();
var template = await _repo.GetDefaultStoreTemplate();
var defaultCurrency = template.GetStoreBlob().DefaultCurrency ?? StoreBlob.StandardDefaultCurrency;
@@ -103,7 +131,19 @@ namespace BTCPayServer.Controllers
rate.RateScripting = false;
}
store.SetStoreBlob(blob);
- await _repo.CreateStore(User.GetId(), store);
+
+ var result = await _repo.CreateStore(User.GetId(), store);
+ if (result == StoreRepository.CreateStoreResult.QuotaExceeded)
+ {
+ ModelState.AddModelError(string.Empty, StringLocalizer["You have reached the maximum number of stores allowed."].Value);
+ var stores = await _repo.GetStoresByUserId(userId);
+ vm.IsFirstStore = !stores.Any();
+ var template = await _repo.GetDefaultStoreTemplate();
+ var defaultCurrency = template.GetStoreBlob().DefaultCurrency ?? StoreBlob.StandardDefaultCurrency;
+ vm.Exchanges = GetExchangesSelectList(defaultCurrency, null);
+ return View(vm);
+ }
+
CreatedStoreId = store.Id;
TempData.SetStatusSuccess(StringLocalizer["Store successfully created"]);
return RedirectToAction(nameof(UIStoresController.Index), "UIStores", new
@@ -112,6 +152,15 @@ namespace BTCPayServer.Controllers
});
}
+ private async Task<int?> GetEffectiveStoreLimitAsync()
+ {
+ if (User.IsInRole(Roles.ServerAdmin))
+ return null;
+ var user = await _userManager.GetUserAsync(User);
+ var blob = user?.GetBlob();
+ return blob?.StoreQuota ?? _policiesSettings.StoreQuota;
+ }
+
[HttpGet("{storeId}/me/delete")]
[Authorize(AuthenticationSchemes = AuthenticationSchemes.Cookie, Policy = Policies.CanModifyStoreSettings)]
public IActionResult DeleteStore(string storeId)
diff --git a/BTCPayServer/Models/ServerViewModels/UsersViewModel.cs b/BTCPayServer/Models/ServerViewModels/UsersViewModel.cs
index 6f8b4e7..b2ab1f3 100644
--- a/BTCPayServer/Models/ServerViewModels/UsersViewModel.cs
+++ b/BTCPayServer/Models/ServerViewModels/UsersViewModel.cs
@@ -44,6 +44,10 @@ namespace BTCPayServer.Models.ServerViewModels
public bool BypassMonetization { get; set; }
public bool MonetizationEnabled { get; set; }
+ [Display(Name = "Store Quota")]
+ [Range(0, int.MaxValue)]
+ public int? StoreQuota { get; set; }
+
public IEnumerable<string> Roles { get; set; }
public IEnumerable<UserStore> Stores { get; set; }
}
diff --git a/BTCPayServer/Services/PoliciesSettings.cs b/BTCPayServer/Services/PoliciesSettings.cs
index 5d592b9..937a2d2 100644
--- a/BTCPayServer/Services/PoliciesSettings.cs
+++ b/BTCPayServer/Services/PoliciesSettings.cs
@@ -99,6 +99,10 @@ namespace BTCPayServer.Services
[Display(Name = "Default store template")]
public JObject DefaultStoreTemplate { get; set; }
+ [Range(0, int.MaxValue)]
+ [Display(Name = "Maximum number of stores non-admins can create")]
+ public int? StoreQuota { get; set; }
+
[Display(Name = "Register page redirect URL")]
public string RegisterPageRedirect { get; set; }
diff --git a/BTCPayServer/Services/Stores/StoreRepository.cs b/BTCPayServer/Services/Stores/StoreRepository.cs
index 0442113..ca07e23 100644
--- a/BTCPayServer/Services/Stores/StoreRepository.cs
+++ b/BTCPayServer/Services/Stores/StoreRepository.cs
@@ -1,6 +1,7 @@
#nullable enable
using System;
using System.Collections.Generic;
+using System.Data;
using System.Linq;
using System.Security.Principal;
using System.Threading.Tasks;
@@ -277,6 +278,16 @@ namespace BTCPayServer.Services.Stores
.ToArrayAsync());
}
+ public async Task<int> CountStoresByUserId(string userId)
+ {
+ if (string.IsNullOrEmpty(userId))
+ return 0;
+ var defaultRoleId = (await GetDefaultRole()).Id;
+ await using var ctx = _ContextFactory.CreateContext();
+ return await ctx.UserStore
+ .CountAsync(u => u.ApplicationUserId == userId && u.StoreRoleId == defaultRoleId);
+ }
+
public async Task<StoreData?> GetStoreByInvoiceId(string invoiceId)
{
await using var context = _ContextFactory.CreateContext();
@@ -428,30 +439,94 @@ namespace BTCPayServer.Services.Stores
store.ApplicationUserId != userId);
}
- public async Task CreateStore(string ownerId, StoreData storeData, StoreRoleId? roleId = null)
+ public enum CreateStoreResult
+ {
+ Created,
+ QuotaExceeded
+ }
+
+ public async Task<CreateStoreResult> CreateStore(string ownerId, StoreData storeData, StoreRoleId? roleId = null)
{
if (!string.IsNullOrEmpty(storeData.Id))
- throw new ArgumentException("id should be empty", nameof(storeData.StoreName));
+ throw new ArgumentException("id should be empty", nameof(storeData.Id));
if (string.IsNullOrEmpty(storeData.StoreName))
throw new ArgumentException("name should not be empty", nameof(storeData.StoreName));
ArgumentNullException.ThrowIfNull(ownerId);
- AssertStoreRoleIfNeeded(storeData.Id, roleId);
- await using var ctx = _ContextFactory.CreateContext();
- storeData.Id = Encoders.Base58.EncodeData(RandomUtils.GetBytes(32));
- roleId ??= await GetDefaultRole();
-
- var userStore = new UserStore
+ var defaultRole = await GetDefaultRole();
+ roleId ??= defaultRole;
+ var id = Encoders.Base58.EncodeData(RandomUtils.GetBytes(32));
+ bool added;
+ await using (var ctx = _ContextFactory.CreateContext())
+ {
+ var conn = ctx.Database.GetDbConnection();
+ added = await conn.ExecuteAsync(
+ """
+ WITH limits AS (
+ SELECT
+ COALESCE(
+ NULLIF(u."Blob2"::jsonb ->> 'storeQuota', '')::integer,
+ NULLIF(s."Value"::jsonb ->> 'StoreQuota', '')::integer
+ ) AS max_store
+ FROM "AspNetUsers" u
+ LEFT JOIN "Settings" s
+ ON s."Id" = 'BTCPayServer.Services.PoliciesSettings'
+ WHERE u."Id" = @userId
+ ),
+ is_admin AS (
+ SELECT EXISTS(
+ SELECT 1
+ FROM "AspNetUserRoles" ur
+ LEFT JOIN "AspNetRoles" r ON ur."RoleId" = r."Id"
+ WHERE ur."UserId" = @userId
+ AND r."NormalizedName" = 'SERVERADMIN'
+ ) AS is_admin
+ ),
+ owned_store_count AS (
+ SELECT COUNT(*) AS current_count
+ FROM "UserStore" us
+ WHERE us."ApplicationUserId" = @userId
+ AND us."Role" = @defaultRole
+ ),
+ quota_check AS (
+ SELECT 1
+ FROM limits l
+ CROSS JOIN owned_store_count c
+ CROSS JOIN is_admin a
+ WHERE l.max_store IS NULL
+ OR c.current_count < l.max_store
+ OR a.is_admin = TRUE
+ ),
+ insert_store AS (
+ INSERT INTO "Stores" ("Id", "SpeedPolicy")
+ SELECT @storeId, 0
+ FROM quota_check
+ RETURNING "Id"
+ )
+ INSERT INTO "UserStore" (
+ "ApplicationUserId",
+ "StoreDataId",
+ "Role"
+ )
+ SELECT
+ @userId,
+ insert_store."Id",
+ @roleId
+ FROM insert_store;
+""",
+ new { userId = ownerId, storeId = id, defaultRole = defaultRole.Id, roleId = roleId.Id }) == 1;
+ if (added)
+ {
+ storeData.Id = id;
+ ctx.Attach(storeData).State = EntityState.Modified;
+ await ctx.SaveChangesAsync();
+ }
+ }
+ if (added)
{
- StoreDataId = storeData.Id,
- ApplicationUserId = ownerId,
- StoreRoleId = roleId.Id,
- };
-
- ctx.Add(storeData);
- ctx.Add(userStore);
- await ctx.SaveChangesAsync();
- _eventAggregator.Publish(new StoreUserEvent.Added(storeData.Id, userStore.ApplicationUserId, roleId.Id));
- _eventAggregator.Publish(new StoreEvent.Created(storeData));
+ _eventAggregator.Publish(new StoreUserEvent.Added(storeData.Id, ownerId, defaultRole.Id));
+ _eventAggregator.Publish(new StoreEvent.Created(storeData));
+ }
+ return added ? CreateStoreResult.Created : CreateStoreResult.QuotaExceeded;
}
public async Task<WebhookData[]> GetWebhooks(string storeId)
diff --git a/BTCPayServer/Services/UserService.cs b/BTCPayServer/Services/UserService.cs
index f6ec7cc..791d1b6 100644
--- a/BTCPayServer/Services/UserService.cs
+++ b/BTCPayServer/Services/UserService.cs
@@ -85,6 +85,7 @@ namespace BTCPayServer.Services
Name = blob.Name,
Roles = roles,
Disabled = data.IsDisabled,
+ StoreQuota = blob.StoreQuota,
ImageUrl = string.IsNullOrEmpty(blob.ImageUrl)
? null
: await uriResolver.Resolve(request.GetAbsoluteRootUri(), UnresolvedUri.Create(blob.ImageUrl)),
diff --git a/BTCPayServer/Views/UIServer/Policies.cshtml b/BTCPayServer/Views/UIServer/Policies.cshtml
index 48dd1df..74028ae 100644
--- a/BTCPayServer/Views/UIServer/Policies.cshtml
+++ b/BTCPayServer/Views/UIServer/Policies.cshtml
@@ -99,6 +99,12 @@
<div class="mb-5">
<h4 class="mb-3" text-translate="true">Users</h4>
+ <div class="form-group">
+ <label asp-for="StoreQuota" class="form-label"></label>
+ <input asp-for="StoreQuota" type="number" min="0" class="form-control" placeholder="@StringLocalizer["Unlimited"]" />
+ <div class="form-text" text-translate="true">Leave empty for unlimited. Set to 0 to prevent non-admins from creating any stores. Per-user overrides can be set on the user's edit page.</div>
+ <span asp-validation-for="StoreQuota" class="text-danger"></span>
+ </div>
<div class="d-flex my-3">
<input asp-for="AllowLightningInternalNodeForAll" type="checkbox" class="btcpay-toggle me-3"/>
<div>
diff --git a/BTCPayServer/Views/UIServer/User.cshtml b/BTCPayServer/Views/UIServer/User.cshtml
index 2c21ca8..727af16 100644
--- a/BTCPayServer/Views/UIServer/User.cshtml
+++ b/BTCPayServer/Views/UIServer/User.cshtml
@@ -83,6 +83,12 @@
<small class="text-muted">When enabled, this user will not be required to have an active subscription.</small>
</div>
}
+ <div class="form-group">
+ <label asp-for="StoreQuota" class="form-label"></label>
+ <input asp-for="StoreQuota" type="number" min="0" class="form-control" placeholder="@StringLocalizer["Use server default"]" />
+ <div class="form-text" text-translate="true">Override the server default maximum number of stores for this user. Leave empty to use the server default.</div>
+ <span asp-validation-for="StoreQuota" class="text-danger"></span>
+ </div>
@if (Model.Approved.HasValue)
{
<div class="form-check my-3">
Why this scored 22/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.