fix(subgroups): read the admin list off the group's data, not off a flag no creator ever sets

"Only an admin of this group can make a subgroup", said to the admin who had just
made the group. The guard was reading the wrong thing.

**`Participant.adminAt` is written in exactly one place**:
`MarmotInboundManager.processGroupMembershipChanges`, reached only from `NostrDao`
on a `GroupEventResult.CommitProcessed` -- an *arriving* commit. The rows a
creator makes come from `getOrCreateChatRoom` and `addMembers`, neither of which
sets it. So on the device that created a room, every participant reads as a
non-admin, including the creator, until some other member sends a commit it
processes. A freshly made `#admins` room has had no commits at all, so a group
whose epoch-0 context names three admins has none of them flagged on the one
device certain to be one.

**The guard now reads `MarmotGroupData.adminPubkeys`** off the room's own MLS
state. That is the thing the column is a cache of: MIP-01 stamps it into the
epoch-0 group context, every member gets it in their Welcome, and
`processGroupMembershipChanges` reads exactly this when it writes the flag.
Reading it directly cannot drift from what the group agreed and is right on both
sides from the first moment. A room whose group data cannot be read refuses rather
than falling back to the column.

The compiler caught a second bug while this was being written: the parent's admin
list was named `adminPublicKeys`, shadowing the parameter of the same name that
holds the *subgroup's* picked admins -- and the ceremony room is derived from that
parameter. It is now `parentAdminPublicKeys`, with a comment saying why the two
must never be confused.

**The column is caught up as well, because the UI still labels members with it.**
`MarmotGroupCreation` stamps `adminAt` on the rows of the members the room was
created with as admins -- the same list baked into `MarmotGroupData` a few lines
above, so nothing new is asserted. Flags only: `processGroupMembershipChanges`
also removes participants missing from the MLS tree, and running the whole
reconciliation would soft-delete the rows of anyone `addMembers` could not reach,
turning a partial invite into a partial membership. Widening a flag is safe;
narrowing membership on a best-effort step is not. Swallowed on failure like
`adopt` beside it: a missing flag costs a button not being offered, not
correctness.

**The old fixture was testing nothing.** It built the parent as a NIP-17 room with
MLS state bolted on, and `ChatRoom.deriveChatRoomId` returns a 33-byte compressed
key (66 hex) while `MarmotGroupData.nostrGroupId` takes 32 -- so `toExtension()`
produced an extension `currentMarmotData()` read back as **null**, silently,
taking the admin list with it. Every guard test was passing on that null. The
parent is now built as what a real one is: the `#admins` room derived from the
group's key, with `adminAt` deliberately left null on every row, which is the
state the guard has to work in.

Four new tests. Two in `SubgroupManagerJvmTest`: an admin is admitted with every
`adminAt` still null, and a room with no MLS state has no admin list to consult.
Two in a new `MarmotGroupCreationJvmTest`: the creator is flagged an admin of the
room they just made, and the room can read its own group data back -- the second
asserting `nostrGroupId` is the 64-hex derived id, since the wrong length there
fails by returning null rather than by throwing.

397 common tests, 712 jvm tests, `m3Audit` meets every budget.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Kgothatso Ngako
2026-09-09 00:30:53 +02:00
parent 0180425904
commit ccaef5d36a
4 changed files with 310 additions and 24 deletions

View File

@@ -11,6 +11,7 @@ import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.first
import kotlin.time.Clock
import kotlinx.coroutines.withTimeoutOrNull
import press.mantra.compose.database.MantraDatabase
import press.mantra.compose.database.model.MarmotKeyPackage
@@ -180,12 +181,62 @@ object MarmotGroupCreation {
logger.w("$groupId created without ${notAdded.size} member(s): $notAdded")
}
stampAdmins(database, groupId, adminPublicKeys)
return Outcome.Created(
room = chatRepository.getChatRoomByIdentifier(groupId) ?: localChatRoom,
notAdded = notAdded
)
}
/**
* Writes `adminAt` on the rows of the members this room was created with as
* admins.
*
* The creating device never learns this any other way, which is a bug older
* than subgroups and easy to miss. `Participant.adminAt` is written in exactly
* one place -- `MarmotInboundManager.processGroupMembershipChanges`, on an
* arriving commit -- while the rows a creator makes come from
* `getOrCreateChatRoom` and `addMembers`, neither of which sets it. So on the
* device that made the room, every participant reads as a non-admin until some
* *other* member sends a commit it processes: a room whose epoch-0 context
* names three admins had none of them flagged, on the one device certain to be
* one.
*
* This is the same list already baked into `MarmotGroupData` a few lines
* above, so nothing new is being asserted -- the row is being caught up with
* the group context it was created from. `processGroupMembershipChanges`
* reconciles it against the MLS tree from then on and will agree.
*
* Only flags are written. That function also *removes* participants missing
* from the tree, and running the whole reconciliation here would soft-delete
* the rows of anyone `addMembers` could not reach -- turning a partial invite
* into a partial membership. Widening a flag is safe; narrowing membership on
* a best-effort step is not.
*
* Swallowed on failure, like `adopt` above it: the room is made and usable,
* and what a missing flag costs is a button not being offered rather than
* anything being wrong.
*/
private suspend fun stampAdmins(
database: MantraDatabase,
groupId: String,
adminPublicKeys: Set<HexKey>
) {
runCatching {
val room = database.chatRoomDao().findChatRoomById(groupId) ?: return
val stamped = room.localParticipants
.map { it.participant }
.filter { it.participantPublicKey in adminPublicKeys && it.adminAt == null }
.map { it.copy(adminAt = Clock.System.now()) }
if (stamped.isNotEmpty()) database.participantDao().upsert(stamped)
}.onFailure {
logger.e("Created $groupId but could not flag its admins", it)
}
}
/**
* Every peer's published key package, or null where there is none.
*

View File

@@ -114,13 +114,36 @@ object SubgroupManager {
// A non-admin proposing the group's signature is a proposal the admins
// have to decline by hand, which is a worse outcome than not offering it.
// Read off the epoch-0 admin list MIP-01 carries, the same reading every
// side of the group makes.
val isAdmin = parentRoom.localParticipants.any {
it.participant.participantPublicKey == coordinatorPublicKey &&
it.participant.adminAt != null
//
// Read off the room's own `MarmotGroupData`, not off `Participant.adminAt`.
// That column is written in exactly one place -- `processGroupMembershipChanges`,
// on an arriving commit -- so on the device that *created* a room it is
// null for everybody, including the creator, until some other member
// sends a commit. A freshly made room has had no commits, so the flag
// says nobody administers a group whose epoch-0 context names three
// admins.
//
// The group data is the thing that column is a cache of: MIP-01 stamps
// `admin_pubkeys` into the epoch-0 group context, every member gets it in
// their Welcome, and `processGroupMembershipChanges` reads exactly this
// when it writes the flag. Reading it directly cannot drift from what the
// group agreed, and is right on both sides from the first moment.
// Named for the parent, because `adminPublicKeys` in this function is the
// *subgroup's* picked admins and the two must never be confused -- the
// ceremony room is derived from the second, and deriving it from the
// first would silently make a different room.
val parentAdminPublicKeys = runCatching {
parentRoom.chatRoom.toMlsGroup()?.currentMarmotData()?.adminPubkeys
}.getOrNull()
if (parentAdminPublicKeys == null) {
// No MLS state, or state this build cannot read. A NIP-17 room has no
// admin list to consult and no group context to put a subgroup's
// certificate in, which is the same reason the button is hidden there.
return "A subgroup can only be made from a group that has admins."
}
if (!isAdmin) {
if (!parentAdminPublicKeys.contains(coordinatorPublicKey)) {
return "Only an admin of this group can make a subgroup."
}