diff --git a/docs/admin/applications.md b/docs/admin/applications.md
index 0fa04f01..83a07664 100644
--- a/docs/admin/applications.md
+++ b/docs/admin/applications.md
@@ -138,7 +138,7 @@ re-inherits the realm.
| Section | What it does |
| --- | --- |
-| **Origin** | The App's own subdomain (e.g. `acme.cocoar.app`), which must be a child of the realm's primary domain. Setting it routes that host to this App and serves the branded login there; clearing it falls back to the tenant URL. The OIDC issuer stays the tenant's (anchored to the realm primary domain) — a subdomain is not its own issuer. |
+| **Origin** | The App's own subdomain (e.g. `acme.cocoar.app`), which must be a child of the realm's primary domain. Setting it routes that host to this App and serves the branded login there; clearing it falls back to the tenant URL. The host must not belong to another realm; it may also be listed among this realm's own domains (it routes to the realm either way, the App match takes precedence). The OIDC issuer stays the tenant's (anchored to the realm primary domain) — a subdomain is not its own issuer. |
| **Branding** | Product name, primary colour, logo/favicon — the look of the login + consent UI when reached via this App. |
| **Email branding** | Product name, sender display name, sender address, validated reply-to address, optional subject prefix, preheader and footer used in this App's outbound emails (OTP, magic link, reset, verification, ...). The sender address falls back to the realm's, then the deployment's; a custom address must be deliverable from your mail provider (SPF/DKIM). Logo and button colour follow effective App branding. See [Transactional email](../platform/email-customization). |
| **Login methods** | Enable/disable internal password/passkey and magic-link entry points, and select/order the external OIDC/SAML providers exposed to this App. The allow-list is enforced by the public list and protocol start endpoints, not only hidden in the SPA. An explicit empty provider list disables all external providers. |
diff --git a/src/dotnet/Modgud.Api.Tests/Authorization/ApplicationSettingsAdminTests.cs b/src/dotnet/Modgud.Api.Tests/Authorization/ApplicationSettingsAdminTests.cs
index f3f96b8e..a007e572 100644
--- a/src/dotnet/Modgud.Api.Tests/Authorization/ApplicationSettingsAdminTests.cs
+++ b/src/dotnet/Modgud.Api.Tests/Authorization/ApplicationSettingsAdminTests.cs
@@ -226,6 +226,67 @@ public async Task Subdomain_Is_Unique_Across_Apps()
Assert.Equal(HttpStatusCode.Conflict, second.StatusCode);
}
+ ///
+ /// Reported from the test instance: the realm listed an App's host among its own
+ /// plain domains as well (a legal state — the host routes to the realm either
+ /// way, the App match layers on top), and from then on every manifest apply died
+ /// on that App with SubdomainTaken ("already a realm domain"), whatever
+ /// the draft changed. Only ANOTHER realm's domain makes a host ambiguous.
+ ///
+ [Fact]
+ public async Task Subdomain_May_Be_A_Domain_Of_The_Own_Realm_But_Not_Of_Another()
+ {
+ var ct = TestContext.Current.CancellationToken;
+ var app = await SeedAppAsync("as-own-domain");
+ var appShort = new ShortGuid(app.Id).ToString();
+ var primary = await SystemPrimaryDomainAsync();
+ var ownHost = $"as-own-domain.{primary}";
+ var foreignHost = $"as-foreign-domain.{primary}";
+ var globalStore = Factory.Services.GetRequiredService();
+ var other = new Realm
+ {
+ Id = Guid.NewGuid(), Slug = "as-other-realm", DisplayName = "Other",
+ Domains = [foreignHost], PrimaryDomain = foreignHost, IsActive = false,
+ };
+
+ await using (var gs = globalStore.LightweightSession())
+ {
+ var system = await gs.Query().FirstAsync(r => r.Slug == "system", ct);
+ system.Domains = [.. system.Domains, ownHost];
+ gs.Store(system);
+ gs.Store(other);
+ await gs.SaveChangesAsync(ct);
+ }
+
+ try
+ {
+ // Own realm's domain: accepted, and accepted again on the re-apply an
+ // idempotent manifest run is.
+ (await PutSettingsAsync(appShort, new ApplicationSettingsDto { Origin = new ApplicationOriginDto { Subdomain = ownHost } }, ct)).EnsureSuccessStatusCode();
+ (await PutSettingsAsync(appShort, new ApplicationSettingsDto { Origin = new ApplicationOriginDto { Subdomain = ownHost } }, ct)).EnsureSuccessStatusCode();
+ await using (var gs = globalStore.QuerySession())
+ {
+ var system = await gs.Query().FirstAsync(r => r.Slug == "system", ct);
+ Assert.Equal(app.Id, system.ApplicationDomains[ownHost]);
+ Assert.Contains(ownHost, system.Domains);
+ }
+
+ // Another realm's domain: still refused.
+ var foreign = await PutSettingsAsync(appShort, new ApplicationSettingsDto { Origin = new ApplicationOriginDto { Subdomain = foreignHost } }, ct);
+ Assert.Equal(HttpStatusCode.Conflict, foreign.StatusCode);
+ Assert.Contains("another realm", await foreign.Content.ReadAsStringAsync(ct));
+ }
+ finally
+ {
+ await using var gs = globalStore.LightweightSession();
+ var system = await gs.Query().FirstAsync(r => r.Slug == "system", ct);
+ system.Domains = system.Domains.Where(d => d != ownHost).ToArray();
+ gs.Store(system);
+ gs.Delete(other);
+ await gs.SaveChangesAsync(ct);
+ }
+ }
+
[Fact]
public async Task Clearing_Origin_Removes_The_Global_Route()
{
diff --git a/src/dotnet/Modgud.Authentication/Applications/ApplicationSettingsService.cs b/src/dotnet/Modgud.Authentication/Applications/ApplicationSettingsService.cs
index ee5f281b..4e94b177 100644
--- a/src/dotnet/Modgud.Authentication/Applications/ApplicationSettingsService.cs
+++ b/src/dotnet/Modgud.Authentication/Applications/ApplicationSettingsService.cs
@@ -289,13 +289,17 @@ public async Task> ValidateOriginAsync(
return Error.Validation("Application.SubdomainNotUnderPrimary",
$"Subdomain must be a child of the realm's primary domain ('{realm.PrimaryDomain}').");
- // Cross-realm uniqueness: the host must not be claimed by any realm's
- // plain domains or another App's route.
+ // Cross-realm uniqueness: the host must not be claimed by ANOTHER realm's
+ // plain domains or by another App's route. The own realm's plain domain
+ // list is not a conflict: such a host resolves to this tenant either way,
+ // and the App route layers on top (the App match wins in RealmCacheLookup).
+ // Refusing it made every manifest apply on a realm that lists an App's host
+ // among its own domains die on that App — whatever the draft changed.
var allRealms = await gsession.Query().ToListAsync(ct);
foreach (var r in allRealms)
{
- if (r.Domains.Any(d => string.Equals(d, subdomain, StringComparison.OrdinalIgnoreCase)))
- return Error.Conflict("Application.SubdomainTaken", "That host is already a realm domain.");
+ if (r.Id != realm.Id && r.Domains.Any(d => string.Equals(d, subdomain, StringComparison.OrdinalIgnoreCase)))
+ return Error.Conflict("Application.SubdomainTaken", "That host is already a domain of another realm.");
foreach (var kv in r.ApplicationDomains)
{
if (string.Equals(kv.Key, subdomain, StringComparison.OrdinalIgnoreCase)