Follow what the live server does with tenants and domains

A run on the production server with throwaway tenants, a role, lists and a
domain, all removed, found three things the source reading had not:

- Something in a tenant has to be on a domain in that tenant (a list in a
  tenant on an unassigned domain is invalidForeignKey), while something in
  no tenant may be on a tenant's domain. The account panel's tenant choice
  offered every tenant; it offers only the domain's now, and a new account
  starts in the tenant of the domain it is made on. The domain list reads
  memberTenantId for it.
- A domain created in a tenant puts its DKIM keys there too, and they keep
  the tenant from being deleted. They are counted with the rest, so Delete
  is not offered while any remain.
- Stalwart lets a domain leave a tenant while the tenant still has accounts
  on it, stranding them. The panel asks first and refuses while any are
  there.

The refusal to delete a tenant that holds anything was confirmed, as were
tenant create, quota pointers, logo and rename. The mock follows the domain
rule, filters DKIM keys by tenant, and KNOWN-ISSUES records the run.

The non-Enterprise notice is now just "Tenants are a Stalwart Enterprise
feature." Two sentences were reworded and one plural added, in all nine
catalogues, and the old sentences are gone.
This commit is contained in:
2026-09-15 09:37:00 -07:00
parent ce5eb04c2d
commit 40df0f658b
21 changed files with 211 additions and 48 deletions
+1 -1
View File
@@ -21,7 +21,7 @@ describe("what a tenant holds", () => {
if (method === "x:Role/query") throw new Error("forbidden");
return { total: method === "x:Account/query" && f["@type"] === "Group" ? 2 : 1 };
});
expect(await countTenantMembers("t1")).toEqual({ accounts: 1, groups: 2, lists: 1, domains: 1 });
expect(await countTenantMembers("t1")).toEqual({ accounts: 1, groups: 2, lists: 1, domains: 1, dkimKeys: 1 });
expect(call).toHaveBeenCalledWith("x:Account/query", { filter: { "@type": "User", memberTenantId: "t1" }, limit: 0, calculateTotal: true });
expect(call).toHaveBeenCalledWith("x:Domain/query", { filter: { memberTenantId: "t1" }, limit: 0, calculateTotal: true });
call.mockRestore();
+3 -1
View File
@@ -63,6 +63,8 @@ export interface DirectoryAccount {
export interface DirectoryDomain {
id: string;
name: string;
/** The tenant the domain is in: an account can be in a tenant only on one of its domains. */
memberTenantId?: string | null;
}
const ACCOUNT_PROPERTIES = [
@@ -121,7 +123,7 @@ async function all<T>(object: "Domain" | "Role", properties: string[]): Promise<
return res.list;
}
export const listDomains = () => all<DirectoryDomain>("Domain", ["name"]);
export const listDomains = () => all<DirectoryDomain>("Domain", ["name", "memberTenantId"]);
export const listRoles = () => all<RoleDef>("Role", ["description", "enabledPermissions", "roleIds"]);
export async function listGroups(): Promise<DirectoryAccount[]> {
+14
View File
@@ -47,6 +47,8 @@ export const TENANT_MEMBERS = [
{ key: "lists", method: "x:MailingList/query", filter: {}, quota: "maxMailingLists" },
{ key: "domains", method: "x:Domain/query", filter: {}, quota: "maxDomains" },
{ key: "roles", method: "x:Role/query", filter: {}, quota: "maxRoles" },
// A domain's keys join the tenant it was created in, and keep it there.
{ key: "dkimKeys", method: "x:DkimSignature/query", filter: {}, quota: "maxDkimKeys" },
] as const;
export type TenantMemberKind = (typeof TENANT_MEMBERS)[number]["key"];
@@ -118,6 +120,18 @@ export async function tenantDomains(tenantId: string): Promise<{ inTenant: Array
};
}
/**
* How many of a tenant's accounts and groups are on a domain.
*
* Stalwart lets a domain leave a tenant while the tenant still has accounts on
* it (live, 2026-09-15), leaving them in a tenant on a domain outside it --
* which it refuses to create. The panel asks this before it offers the move.
*/
export async function tenantAccountsOnDomain(tenantId: string, domainId: string): Promise<number> {
const res = await client.call<{ total?: number }>("x:Account/query", { filter: { domainId, memberTenantId: tenantId }, limit: 0, calculateTotal: true });
return res.total ?? 0;
}
/** Put a domain in a tenant, or take it out with null. */
export async function setDomainTenant(domainId: string, tenantId: string | null): Promise<void> {
const res = await client.call<SetResponse>("x:Domain/set", { update: { [domainId]: { memberTenantId: tenantId } } });