Record the groups run on the live server, and match its refusal shape

A throwaway group on the production server confirmed what the code was
built on: a Group account with Default roles, membership as a pointer on
the member, the members query, and a delete refused while a member still
names the group. Its objectId is an {object, id} pair rather than a bare
id; the mock answers the same way now.
This commit is contained in:
2026-09-15 08:37:39 -07:00
parent e2a531b615
commit 79afc334ab
2 changed files with 10 additions and 4 deletions
+7 -1
View File
@@ -65,7 +65,13 @@ works the same way — and dropped where 0.15 was the whole subject. Support for
**Not confirmed live:** that a tenant administrator's counts are scoped to the tenancy, and that a Community server refuses `x:Metric` as `forbidden`. Both are read from the 0.16.22 source (`query.rs`, `queued_message.rs`, `registry/mod.rs`); the production server has no tenants and is Enterprise, so neither could be tried there without writing. The dashboard's handling of both is covered by tests against the refusal Stalwart's source gives.
- **Groups were built from the 0.16.22 source and the mock; nothing about them has been tried on a live server yet.** The production server has no groups, and every group operation is a write. Read from source rather than run: a group is `x:Account` with `@type: "Group"` and roles `Default` or `Custom`; membership is each user's `memberGroupIds`, found with `{"@type": "User", "memberGroupIds": id}` and changed with a `memberGroupIds/<id>` pointer of `true` or `null` (`crates/registry/src/types/map.rs`); membership gives access to what is shared with the group and no permissions (`access_token.rs` builds a user's permissions from their own roles only). **Deleting a group with members is assumed to be refused as `objectIsLinked`**, because the registry keeps anything another object names; ihasmail takes the members out first either way, so the delete works whichever is true, but the refusal itself is unconfirmed.
- **Groups were built from the 0.16.22 source and a mock, then confirmed on the live server (2026-09-15)** with a throwaway `[email protected]`, created and removed, its only member the administrator's own account:
- **A group is created** as `x:Account` with `@type: "Group"`, no credentials and no encryption setting, and reads back with roles `{"@type": "Default"}`, `permissions` `Inherit`, a `locale` of `en-US` and `usedDiskQuota` 0.
- **Membership is the member's.** `"memberGroupIds/<group>": true` on the user was accepted; `{"@type": "User", "memberGroupIds": <group>}` then found them with a total of 1, and the user's own `memberGroupIds` read `{"<group>": true}`. The same pointer with `null` took them out again and left the set as it was before.
- **A group with members cannot be deleted**: `objectIsLinked`, with `objectId` as `{"object": "Account", "id": <group>}` and `linkedObjects` listing each member as `{"object": "Account", "id": …}`. With the member out, the delete went through and the group read back as `notFound`.
Still from source only: that membership gives a member no permissions (`access_token.rs` builds a user's permissions from their own roles), and that groups cannot nest.
- **A refused password shows the server's reason in English.** Every other refusal from the registry is said in the reader's language: each error type has its own message, and a value one of Stalwart's validators refused — a domain name, an address, an empty field — is recognised by the validator's wording and explained again rather than shown. A password policy is the exception, on purpose. Its rule is the server's to set, so there is nothing to translate it from in advance, and its reason follows a translated sentence rather than being dropped, which would leave "not accepted" with no way to find out why.
+3 -3
View File
@@ -373,9 +373,9 @@ export function createDirectory(opts: Options) {
if (i < 0) { notDestroyed[id] = setError("notFound", "Account not found."); continue; }
if (accounts[i]!["@type"] === "Group" && accounts.some((x) => (x.memberGroupIds as Obj | undefined)?.[id])) {
// Every member's memberGroupIds names the group, which is a link the
// registry will not delete through; the shape is what a domain's
// DKIM keys produced on the live server.
notDestroyed[id] = { type: "objectIsLinked", objectId: id, linkedObjects: accounts.filter((x) => (x.memberGroupIds as Obj | undefined)?.[id]).map((x) => ({ object: "Account", id: x.id })) };
// registry will not delete through. The shape is the live server's,
// from a throwaway group on 2026-09-15.
notDestroyed[id] = { type: "objectIsLinked", objectId: { object: "Account", id }, linkedObjects: accounts.filter((x) => (x.memberGroupIds as Obj | undefined)?.[id]).map((x) => ({ object: "Account", id: x.id })) };
continue;
}
accounts.splice(i, 1);