fix(marmot): read and write group metadata through the profile in use

Everything that touched a group's name, admins, relays or avatar went
through `groupMetadata`, which decodes ONLY the legacy `0xF2EE`
extension. It returns null for every current-profile group, so:

  - `amy marmot group show` / `list` / `admins` printed a blank name and
    an empty admin set;
  - `amy marmot await group --name X` never matched, which is what test
    03 was actually reporting — we had joined MDK's group, we just could
    not find it by name;
  - the Android chatroom showed no name, no admins, no relays, no avatar;
  - `group rename` / `promote` / `demote` / `set-image` BOOTSTRAPPED a
    legacy blob and committed it into a current-profile group, so the
    rename appeared to work locally while every peer kept the old name.

Adds `MarmotManager.groupView` (read) and `setGroupProfile` /
`setGroupAdmins` / `setGroupImage` (write). The setters dispatch on the
group's actual profile: a current-profile group takes an
`app_data_update` naming ONE component, so a concurrent admin-policy
change does not lose its work to a rename; a legacy group has no such
separation and its single extension is rewritten whole. Every call site
in the CLI, the Android app and the relay-subscription manager now goes
through them.

`createMarmotGroup` creates a CURRENT-profile group. The profile is
decided once, at creation, and cannot be migrated later — a legacy
group's existing leaves have no account identity proofs to add — so a
group made the old way is joinable only by other legacy clients. The
name and description are passed in at creation because the routing
component has to exist from epoch 0 anyway: it carries the
`nostr_group_id` every kind-445 event in the group is addressed to.

`MarmotGroupIconUpload` gains `mediaType`. MIP-01's image blob never
carried one; the current profile's `0x8002` component requires it on a
present image and binds it into the AEAD's AAD, so a receiver cannot be
steered into decoding the plaintext as a different type than the
uploader meant.

Also: a gift wrap is no longer broadcast to the public default relay set
when the recipient advertised an inbox we declined to reach. "Advertised
nothing" and "advertised only local-network relays" are different facts,
and treating the second as the first sends someone's invite to a relay
set they never chose — the opposite of what the filter is for.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016kCuA6tc4JQzHPCDd39GHq
This commit is contained in:
Claude
2026-09-09 04:55:33 +00:00
parent 4a89d29f6c
commit 894a999689
12 changed files with 306 additions and 137 deletions
@@ -20,6 +20,7 @@
*/
package com.vitorpamplona.amethyst.model
import com.vitorpamplona.quartz.marmot.appComponents.GroupProfileV1
import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageEvent
import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageFetcher
import com.vitorpamplona.quartz.nip01Core.core.Event
@@ -53,7 +54,7 @@ class AccountMarmotActions(
fun marmotGroupRelays(nostrGroupId: HexKey): Set<NormalizedRelayUrl> {
val groupRelays =
account.marmotManager
?.groupMetadata(nostrGroupId)
?.groupView(nostrGroupId)
?.relays
?.mapNotNull {
com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
@@ -386,12 +387,33 @@ class AccountMarmotActions(
}
/**
* Create a new Marmot MLS group.
* Create a new Marmot MLS group under the CURRENT profile.
*
* Not the legacy `0xF2EE` shape. A current-profile peer refuses a leaf
* with no account identity proof, and a legacy group cannot be upgraded
* into one afterwards — its existing leaves have no proofs to add — so the
* profile is decided here, once, and never migrated. Groups made the old
* way are joinable only by other legacy clients.
*
* The name, description and avatar arrive later through
* `updateMarmotGroupMetadata`; the routing component has to exist from
* epoch 0 because it carries the `nostr_group_id` every kind-445 event in
* this group is addressed to.
*/
suspend fun createMarmotGroup(nostrGroupId: HexKey) {
suspend fun createMarmotGroup(
nostrGroupId: HexKey,
name: String = "",
description: String = "",
) {
val manager = account.marmotManager ?: return
if (!account.isWriteable()) return
manager.createGroup(nostrGroupId)
manager.createCurrentProfileGroup(
nostrGroupId = nostrGroupId,
relays =
account.outboxRelays.flow.value
.map { it.url },
profile = if (name.isEmpty() && description.isEmpty()) null else GroupProfileV1(name, description),
)
// Creator owns the group — mark it as "known" immediately so it
// doesn't appear under "New Requests" before the first message.
account.marmotGroupList.markAsKnown(nostrGroupId)
@@ -416,9 +438,9 @@ class AccountMarmotActions(
val manager = account.marmotManager ?: return
if (!account.isWriteable()) return
val metadata = manager.groupMetadata(nostrGroupId)
if (metadata != null && metadata.adminPubkeys.contains(account.signer.pubKey)) {
val remaining = metadata.adminPubkeys.filter { it != account.signer.pubKey }.toMutableList()
val view = manager.groupView(nostrGroupId)
if (view != null && view.adminPubkeys.contains(account.signer.pubKey)) {
val remaining = view.adminPubkeys.filter { it != account.signer.pubKey }.toMutableList()
// MIP-03 also rejects any GCE commit that leaves the group with zero
// admins. If we're the only one, promote an arbitrary non-self
// member to admin before stepping down.
@@ -431,8 +453,7 @@ class AccountMarmotActions(
if (heir != null) remaining.add(heir)
}
if (remaining.isNotEmpty()) {
val demoted = metadata.copy(adminPubkeys = remaining)
manager.updateGroupMetadata(nostrGroupId, demoted, groupRelays.toList())
manager.setGroupAdmins(nostrGroupId, remaining, groupRelays.toList())
}
}
@@ -541,17 +562,10 @@ class AccountMarmotActions(
val manager = account.marmotManager ?: return
if (!account.isWriteable()) return
val metadata = manager.groupMetadata(nostrGroupId) ?: return
if (metadata.adminPubkeys.contains(targetPubKey)) return
val view = manager.groupView(nostrGroupId) ?: return
if (view.adminPubkeys.contains(targetPubKey)) return
val outboxRelayStrings =
account.outboxRelays.flow.value
.map { it.url }
val updated =
metadata
.copy(adminPubkeys = metadata.adminPubkeys + targetPubKey)
.withMergedRelays(outboxRelayStrings)
updateMarmotGroupMetadata(nostrGroupId, updated, groupRelays)
manager.setGroupAdmins(nostrGroupId, view.adminPubkeys + targetPubKey, groupRelays.toList())
}
/**
@@ -568,20 +582,13 @@ class AccountMarmotActions(
val manager = account.marmotManager ?: return
if (!account.isWriteable()) return
val metadata = manager.groupMetadata(nostrGroupId) ?: return
if (!metadata.adminPubkeys.contains(targetPubKey)) return
val remaining = metadata.adminPubkeys.filter { it != targetPubKey }
val view = manager.groupView(nostrGroupId) ?: return
if (!view.adminPubkeys.contains(targetPubKey)) return
val remaining = view.adminPubkeys.filter { it != targetPubKey }
check(remaining.isNotEmpty()) {
"Cannot revoke the last admin from a Marmot group (MIP-03)"
}
val outboxRelayStrings =
account.outboxRelays.flow.value
.map { it.url }
val updated =
metadata
.copy(adminPubkeys = remaining)
.withMergedRelays(outboxRelayStrings)
updateMarmotGroupMetadata(nostrGroupId, updated, groupRelays)
manager.setGroupAdmins(nostrGroupId, remaining, groupRelays.toList())
}
}
@@ -77,7 +77,7 @@ class MarmotGroupEventsEoseManager(
} ?: continue
// Use group-specific relays from MLS metadata; fall back to home relays
val metadata = manager.groupMetadata(groupId)
val metadata = manager.groupView(groupId)
val groupRelays =
metadata
?.relays
@@ -115,11 +115,12 @@ import com.vitorpamplona.quartz.experimental.clink.pointers.NDebit
import com.vitorpamplona.quartz.experimental.ephemChat.chat.RoomId
import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStoryBaseEvent
import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStoryReadingStateEvent
import com.vitorpamplona.quartz.marmot.mip01Groups.MarmotGroupData
import com.vitorpamplona.quartz.marmot.appComponents.GroupBlossomImageV1
import com.vitorpamplona.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle
@@ -2452,8 +2453,12 @@ class AccountViewModel(
fun marmotMediaExporterSecret(nostrGroupId: String): ByteArray? = account.marmotManager?.mediaExporterSecret(nostrGroupId)
suspend fun createMarmotGroup(nostrGroupId: String) {
account.marmot.createMarmotGroup(nostrGroupId)
suspend fun createMarmotGroup(
nostrGroupId: String,
name: String = "",
description: String = "",
) {
account.marmot.createMarmotGroup(nostrGroupId, name, description)
}
suspend fun publishMarmotKeyPackage() {
@@ -2549,35 +2554,26 @@ class AccountViewModel(
// overlap, so kind:445 messages never reach the other side. The
// welcome carries the metadata, so the invitee learns the relays at
// join time.
val outboxRelayStrings =
account.outboxRelays.flow.value
.map { it.url }
val currentMetadata = account.marmotManager?.groupMetadata(nostrGroupId)
val baseMetadata =
currentMetadata
?.copy(name = name, description = description)
?.withMergedRelays(outboxRelayStrings)
?: MarmotGroupData.bootstrap(
nostrGroupId = nostrGroupId,
creatorPubKey = account.signer.pubKey,
outboxRelays = outboxRelayStrings,
name = name,
description = description,
)
val updatedMetadata =
when (icon) {
is MarmotGroupIconChange.Keep -> baseMetadata
is MarmotGroupIconChange.Clear -> baseMetadata.withoutImage()
is MarmotGroupIconChange.Set ->
baseMetadata.withImage(
imageHash = icon.upload.imageHash,
val manager = account.marmotManager ?: return
val relays = account.marmot.marmotGroupRelays(nostrGroupId)
manager.setGroupProfile(nostrGroupId, name, description, relays.toList())
when (icon) {
is MarmotGroupIconChange.Keep -> Unit
is MarmotGroupIconChange.Clear -> manager.setGroupImage(nostrGroupId, null, relays.toList())
is MarmotGroupIconChange.Set ->
manager.setGroupImage(
nostrGroupId,
GroupBlossomImageV1(
imageHash = icon.upload.imageHash.hexToByteArray(),
imageKey = icon.upload.imageKey,
imageNonce = icon.upload.imageNonce,
imageUploadKey = icon.upload.imageUploadKey,
)
}
val relays = account.marmot.marmotGroupRelays(nostrGroupId)
account.marmot.updateMarmotGroupMetadata(nostrGroupId, updatedMetadata, relays)
mediaType = icon.upload.mediaType,
),
relays.toList(),
)
}
}
override fun onCleared() {
@@ -87,7 +87,7 @@ fun CreateGroupScreen(
scope.launch(Dispatchers.IO) {
try {
val nostrGroupId = RandomInstance.bytes(32).toHexKey()
accountViewModel.createMarmotGroup(nostrGroupId)
accountViewModel.createMarmotGroup(nostrGroupId, groupName.trim(), groupDescription.trim())
// Encrypt + upload the picked icon (if any) before the metadata commit,
// so its parameters land in the group's MarmotGroupData extension.
val iconChange =
@@ -51,6 +51,15 @@ class MarmotGroupIconUpload(
val imageNonce: ByteArray,
/** 32-byte HKDF seed for the Blossom-auth keypair (MIP-01 v2). */
val imageUploadKey: ByteArray,
/**
* Media type of the DECRYPTED image.
*
* MIP-01's blob never carried one, but the current profile's `0x8002`
* component requires it on a present image — and it is bound into the
* AEAD's AAD there, so a receiver cannot be steered into decoding the
* plaintext as a different type than the uploader meant.
*/
val mediaType: String,
)
/**
@@ -123,6 +132,7 @@ class MarmotGroupIconUploader(
imageKey = cipher.imageKey,
imageNonce = cipher.imageNonce,
imageUploadKey = uploadKeySeed,
mediaType = uploadMime,
)
}
@@ -145,14 +145,14 @@ object AwaitCommands {
ctx.syncIncoming(timeoutMs = 3_000)
val match =
ctx.marmot.activeGroupIds().firstOrNull { gid ->
wantedName == null || ctx.marmot.groupMetadata(gid)?.name == wantedName
wantedName == null || ctx.marmot.groupView(gid)?.name == wantedName
}
if (match != null) {
Output.emit(
mapOf(
"group_id" to match,
"mls_group_id" to ctx.marmot.mlsGroupIdHex(match),
"name" to (ctx.marmot.groupMetadata(match)?.name ?: ""),
"name" to (ctx.marmot.groupView(match)?.name ?: ""),
"epoch" to ctx.marmot.groupEpoch(match),
),
)
@@ -190,7 +190,7 @@ object AwaitCommands {
if (!ctx.marmot.isMember(gid)) {
null
} else if (ctx.marmot
.groupMetadata(gid)
.groupView(gid)
?.adminPubkeys
?.contains(target) == true
) {
@@ -215,7 +215,7 @@ object AwaitCommands {
val deadline = System.currentTimeMillis() + timeoutSecs * 1000
while (System.currentTimeMillis() < deadline) {
ctx.syncIncoming(timeoutMs = 3_000)
val name = ctx.marmot.groupMetadata(gid)?.name
val name = ctx.marmot.groupView(gid)?.name
if (name == wantedName) {
Output.emit(mapOf("group_id" to gid, "name" to name, "epoch" to ctx.marmot.groupEpoch(gid)))
return 0
@@ -77,10 +77,10 @@ object GroupMembershipCommands {
// leave the group with zero admins (admin depletion). If we're
// the only admin, hand admin to another member first.
val demoteEventId: String? =
ctx.marmot.groupMetadata(gid)?.let { metadata ->
if (!metadata.adminPubkeys.contains(ctx.identity.pubKeyHex)) return@let null
ctx.marmot.groupView(gid)?.let { view ->
if (!view.adminPubkeys.contains(ctx.identity.pubKeyHex)) return@let null
val newAdmins = metadata.adminPubkeys.filter { it != ctx.identity.pubKeyHex }.toMutableList()
val newAdmins = view.adminPubkeys.filter { it != ctx.identity.pubKeyHex }.toMutableList()
if (newAdmins.isEmpty()) {
val heir =
ctx.marmot
@@ -90,8 +90,7 @@ object GroupMembershipCommands {
?: return@let null // solo group — skip demote, let MLS state cleanup handle it
newAdmins.add(heir)
}
val demoted = metadata.copy(adminPubkeys = newAdmins)
val demoteCommit = ctx.marmot.updateGroupMetadata(gid, demoted)
val demoteCommit = ctx.marmot.setGroupAdmins(gid, newAdmins, targets.toList())
ctx.publish(demoteCommit.signedEvent, targets)
demoteCommit.signedEvent.id
}
@@ -24,12 +24,15 @@ import com.vitorpamplona.amethyst.cli.Args
import com.vitorpamplona.amethyst.cli.Context
import com.vitorpamplona.amethyst.cli.DataDir
import com.vitorpamplona.amethyst.cli.Output
import com.vitorpamplona.amethyst.commons.marmot.MarmotManager
import com.vitorpamplona.amethyst.commons.service.upload.BlossomAuth
import com.vitorpamplona.amethyst.commons.service.upload.BlossomClient
import com.vitorpamplona.amethyst.commons.util.deleteOrWarn
import com.vitorpamplona.quartz.marmot.mip01Groups.MarmotGroupData
import com.vitorpamplona.quartz.marmot.OutboundGroupEvent
import com.vitorpamplona.quartz.marmot.appComponents.GroupBlossomImageV1
import com.vitorpamplona.quartz.marmot.mip01Groups.MarmotGroupImageEncryption
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
import java.io.File
@@ -44,7 +47,9 @@ object GroupMetadataCommands {
rest: Array<String>,
): Int {
if (rest.size < 2) return Output.error("bad_args", "group rename <gid> <name>")
return edit(dataDir, rest[0]) { _, cur -> cur.copy(name = rest[1]) }
return commit(dataDir, rest[0]) { ctx, gid, view ->
ctx.marmot.setGroupProfile(gid, rest[1], view.description)
}
}
suspend fun promote(
@@ -52,11 +57,9 @@ object GroupMetadataCommands {
rest: Array<String>,
): Int {
if (rest.size < 2) return Output.error("bad_args", "group promote <gid> <npub>")
return edit(dataDir, rest[0]) { ctx, cur ->
return commit(dataDir, rest[0]) { ctx, gid, view ->
val newAdmin = ctx.requireUserHex(rest[1])
val admins = cur.adminPubkeys.toMutableList()
if (newAdmin !in admins) admins.add(newAdmin)
cur.copy(adminPubkeys = admins)
ctx.marmot.setGroupAdmins(gid, (view.adminPubkeys + newAdmin).distinct())
}
}
@@ -65,10 +68,9 @@ object GroupMetadataCommands {
rest: Array<String>,
): Int {
if (rest.size < 2) return Output.error("bad_args", "group demote <gid> <npub>")
return edit(dataDir, rest[0]) { ctx, cur ->
return commit(dataDir, rest[0]) { ctx, gid, view ->
val target = ctx.requireUserHex(rest[1])
val admins = cur.adminPubkeys.filter { it != target }
cur.copy(adminPubkeys = admins)
ctx.marmot.setGroupAdmins(gid, view.adminPubkeys.filter { it != target })
}
}
@@ -114,8 +116,17 @@ object GroupMetadataCommands {
}
}
return edit(dataDir, gid, mapOf("image_hash" to enc.imageHash, "image_url" to uploadedUrl)) { _, cur ->
cur.withImage(enc.imageHash, enc.imageKey, enc.imageNonce, uploadKeySeed)
return commit(dataDir, gid, mapOf("image_hash" to enc.imageHash, "image_url" to uploadedUrl)) { ctx, resolved, _ ->
ctx.marmot.setGroupImage(
resolved,
GroupBlossomImageV1(
imageHash = enc.imageHash.hexToByteArray(),
imageKey = enc.imageKey,
imageNonce = enc.imageNonce,
imageUploadKey = uploadKeySeed,
mediaType = args.flag("mime") ?: "image/jpeg",
),
)
}
}
@@ -125,40 +136,43 @@ object GroupMetadataCommands {
rest: Array<String>,
): Int {
if (rest.isEmpty()) return Output.error("bad_args", "group clear-image <gid>")
return edit(dataDir, rest[0]) { _, cur -> cur.withoutImage() }
return commit(dataDir, rest[0]) { ctx, gid, _ -> ctx.marmot.setGroupImage(gid, null) }
}
private suspend fun edit(
/**
* Run one metadata commit and report it.
*
* The mutation goes through [MarmotManager]'s profile-agnostic setters
* rather than being applied to a legacy `MarmotGroupData` here. Building
* that blob locally was the bug: `groupMetadata` is null for every
* current-profile group, so this bootstrapped a legacy `0xF2EE` extension
* and committed it INTO a current-profile group — the rename appeared to
* succeed locally and every peer kept showing the old name.
*/
private suspend fun commit(
dataDir: DataDir,
rawGid: HexKey,
extra: Map<String, Any?> = emptyMap(),
mutate: suspend (Context, MarmotGroupData) -> MarmotGroupData,
mutate: suspend (Context, HexKey, MarmotManager.GroupView) -> OutboundGroupEvent,
): Int {
Context.open(dataDir).use { ctx ->
ctx.prepare()
val gid = ctx.resolveGroupId(rawGid)
ctx.syncIncoming()
if (!ctx.marmot.isMember(gid)) return Output.error("not_member", "not a member of group $gid")
val outboxUrls = ctx.outboxRelays().map { it.url }
val cur =
ctx.marmot.groupMetadata(gid)
?: MarmotGroupData.bootstrap(
nostrGroupId = gid,
creatorPubKey = ctx.identity.pubKeyHex,
outboxRelays = outboxUrls,
)
val updated = mutate(ctx, cur).withMergedRelays(outboxUrls)
val view = ctx.marmot.groupView(gid) ?: return Output.error("not_member", "not a member of group $gid")
val commit = ctx.marmot.updateGroupMetadata(gid, updated)
val commit = mutate(ctx, gid, view)
val targets = ctx.marmotGroupRelays(gid).ifEmpty { ctx.outboxRelays() }
val ack = ctx.publish(commit.signedEvent, targets)
RawEventSupport.publishGuard(ack, commit.signedEvent.id)?.let { return it }
val after = ctx.marmot.groupView(gid)
Output.emit(
mapOf(
"group_id" to gid,
"name" to updated.name,
"admins" to updated.adminPubkeys,
"name" to (after?.name ?: view.name),
"admins" to (after?.adminPubkeys ?: view.adminPubkeys),
"epoch" to ctx.marmot.groupEpoch(gid),
"commit_event_id" to commit.signedEvent.id,
) + RawEventSupport.ackFields(ack) + extra,
@@ -35,7 +35,7 @@ object GroupReadCommands {
val ids = ctx.marmot.activeGroupIds()
val items =
ids.map { id ->
val m = ctx.marmot.groupMetadata(id)
val m = ctx.marmot.groupView(id)
mapOf(
"group_id" to id,
"name" to (m?.name ?: ""),
@@ -58,7 +58,7 @@ object GroupReadCommands {
val gid = ctx.resolveGroupId(rest[0])
ctx.syncIncoming()
if (!ctx.marmot.isMember(gid)) return Output.error("not_member", gid)
val meta = ctx.marmot.groupMetadata(gid)
val meta = ctx.marmot.groupView(gid)
val members =
ctx.marmot.memberPubkeys(gid).map {
mapOf("pubkey" to it.pubkey, "leaf_index" to it.leafIndex)
@@ -109,7 +109,7 @@ object GroupReadCommands {
val gid = ctx.resolveGroupId(rest[0])
ctx.syncIncoming()
if (!ctx.marmot.isMember(gid)) return Output.error("not_member", gid)
val m = ctx.marmot.groupMetadata(gid)
val m = ctx.marmot.groupView(gid)
Output.emit(mapOf("group_id" to gid, "admins" to (m?.adminPubkeys ?: emptyList())))
return 0
}
@@ -30,7 +30,9 @@ import com.vitorpamplona.quartz.marmot.MarmotWelcomeSender
import com.vitorpamplona.quartz.marmot.OutboundGroupEvent
import com.vitorpamplona.quartz.marmot.WelcomeDelivery
import com.vitorpamplona.quartz.marmot.WelcomeResult
import com.vitorpamplona.quartz.marmot.appComponents.AdminPolicyV1
import com.vitorpamplona.quartz.marmot.appComponents.CurrentProfileGroupFactory
import com.vitorpamplona.quartz.marmot.appComponents.GroupBlossomImageV1
import com.vitorpamplona.quartz.marmot.appComponents.GroupProfileV1
import com.vitorpamplona.quartz.marmot.appComponents.MarmotGroupState
import com.vitorpamplona.quartz.marmot.appComponents.MessageRetentionV1
@@ -817,6 +819,146 @@ class MarmotManager(
}.event
}
/**
* A group's metadata read through whichever profile it actually uses.
*
* Every caller that wants a name, an admin list or an avatar wants this,
* not [groupMetadata]: the legacy accessor returns null for every
* current-profile group, so the UI, the CLI and the await verbs all showed
* a blank name and an empty admin set for groups that were perfectly fine.
*/
class GroupView(
val name: String,
val description: String,
val adminPubkeys: List<HexKey>,
val relays: List<String>,
val image: MarmotGroupImage?,
/** True when the group requires `0x8009` — see [MarmotGroupState.isCurrentProfile]. */
val isCurrentProfile: Boolean,
)
fun groupView(nostrGroupId: HexKey): GroupView? {
val group = groupManager.getGroup(nostrGroupId) ?: return null
val state = group.currentGroupState()
val legacy = MarmotGroupData.fromExtensions(group.extensions)
val image = state.image
return GroupView(
name = state.profile?.name?.takeIf { it.isNotEmpty() } ?: legacy?.name.orEmpty(),
description = state.profile?.description?.takeIf { it.isNotEmpty() } ?: legacy?.description.orEmpty(),
adminPubkeys = state.adminPolicy?.adminHexKeys ?: legacy?.adminPubkeys.orEmpty(),
relays = state.routing?.relays ?: legacy?.relays.orEmpty(),
image =
when {
image?.imageHash != null ->
MarmotGroupImage(image.imageHash!!.toHexKey(), image.imageKey!!, image.imageNonce!!)
legacy?.hasImage() == true ->
MarmotGroupImage(legacy.imageHash!!, legacy.imageKey!!, legacy.imageNonce!!)
else -> null
},
isCurrentProfile = state.isCurrentProfile,
)
}
/**
* Rename a group, writing to whichever carrier the group actually uses.
*
* A current-profile group takes an `app_data_update` naming ONLY the
* profile component, so a concurrent admin-policy change does not lose its
* work to this one. A legacy group has no such separation — its single
* `0xF2EE` extension is rewritten whole.
*/
suspend fun setGroupProfile(
nostrGroupId: HexKey,
name: String,
description: String,
relays: List<NormalizedRelayUrl> = groupRelays(nostrGroupId),
): OutboundGroupEvent {
val view = groupView(nostrGroupId) ?: throw IllegalStateException("Not a member of group $nostrGroupId")
if (!view.isCurrentProfile) {
val legacy =
groupMetadata(nostrGroupId)
?: throw IllegalStateException("Legacy group $nostrGroupId has no MarmotGroupData")
return updateGroupMetadata(nostrGroupId, legacy.copy(name = name, description = description), relays)
}
return commitAndPublish(nostrGroupId, relays) {
groupManager.stageAppDataUpdate(
nostrGroupId,
GroupProfileV1.COMPONENT_ID,
GroupProfileV1(name, description).encode(),
)
}.event
}
/**
* Replace the group's admin set, writing to whichever carrier the group uses.
*
* Refuses an empty set. Both profiles reject a group with no admins — a
* groupthat can never again change its own state is not a state anyone
* can recover from, so the check belongs here rather than at each caller.
*/
suspend fun setGroupAdmins(
nostrGroupId: HexKey,
admins: List<HexKey>,
relays: List<NormalizedRelayUrl> = groupRelays(nostrGroupId),
): OutboundGroupEvent {
require(admins.isNotEmpty()) { "a Marmot group cannot be left with no admins" }
val view = groupView(nostrGroupId) ?: throw IllegalStateException("Not a member of group $nostrGroupId")
if (!view.isCurrentProfile) {
val legacy =
groupMetadata(nostrGroupId)
?: throw IllegalStateException("Legacy group $nostrGroupId has no MarmotGroupData")
return updateGroupMetadata(nostrGroupId, legacy.copy(adminPubkeys = admins), relays)
}
return commitAndPublish(nostrGroupId, relays) {
groupManager.stageAppDataUpdate(
nostrGroupId,
AdminPolicyV1.COMPONENT_ID,
AdminPolicyV1.ofHex(admins).encode(),
)
}.event
}
/**
* Set or clear the group avatar, writing to whichever carrier the group uses.
*
* [image] null clears it: the current profile removes the `0x8002`
* component outright rather than storing an "absent" encoding, so a group
* with no avatar carries no avatar state.
*/
suspend fun setGroupImage(
nostrGroupId: HexKey,
image: GroupBlossomImageV1?,
relays: List<NormalizedRelayUrl> = groupRelays(nostrGroupId),
): OutboundGroupEvent {
val view = groupView(nostrGroupId) ?: throw IllegalStateException("Not a member of group $nostrGroupId")
if (!view.isCurrentProfile) {
val legacy =
groupMetadata(nostrGroupId)
?: throw IllegalStateException("Legacy group $nostrGroupId has no MarmotGroupData")
val updated =
if (image?.imageHash == null) {
legacy.withoutImage()
} else {
legacy.withImage(
image.imageHash!!.toHexKey(),
image.imageKey!!,
image.imageNonce!!,
image.imageUploadKey!!,
)
}
return updateGroupMetadata(nostrGroupId, updated, relays)
}
return commitAndPublish(nostrGroupId, relays) {
groupManager.stageAppDataUpdate(
nostrGroupId,
GroupBlossomImageV1.COMPONENT_ID,
image?.encode(),
)
}.event
}
// --- KeyPackage Management ---
/**
@@ -1027,44 +1169,18 @@ class MarmotManager(
nostrGroupId: HexKey,
chatroom: MarmotGroupChatroom,
) {
// Read the current profile's components first, then the legacy
// 0xF2EE extension. Reading only the legacy one left every
// current-profile group with a blank name, no admins, no relays and no
// avatar in the UI — the group worked, it just looked empty.
val state = groupState(nostrGroupId)
val legacy = groupMetadata(nostrGroupId)
val name = state?.profile?.name?.takeIf { it.isNotEmpty() } ?: legacy?.name
if (!name.isNullOrEmpty()) chatroom.displayName.value = name
val description = state?.profile?.description?.takeIf { it.isNotEmpty() } ?: legacy?.description
if (!description.isNullOrEmpty()) chatroom.description.value = description
val admins = state?.adminPolicy?.adminHexKeys ?: legacy?.adminPubkeys
if (admins != null) chatroom.adminPubkeys.value = admins
val relays = state?.routing?.relays ?: legacy?.relays
if (relays != null) chatroom.relays.value = relays
val image = state?.image
chatroom.image.value =
when {
image?.imageHash != null ->
MarmotGroupImage(
hash = image.imageHash!!.toHexKey(),
key = image.imageKey!!,
nonce = image.imageNonce!!,
)
legacy?.hasImage() == true ->
MarmotGroupImage(
hash = legacy.imageHash!!,
key = legacy.imageKey!!,
nonce = legacy.imageNonce!!,
)
else -> null
}
// Read through [groupView], not [groupMetadata]: the legacy accessor
// returns null for every current-profile group, which left them with a
// blank name, no admins, no relays and no avatar in the UI. The group
// worked; it just looked empty.
val view = groupView(nostrGroupId)
if (view != null) {
if (view.name.isNotEmpty()) chatroom.displayName.value = view.name
if (view.description.isNotEmpty()) chatroom.description.value = view.description
chatroom.adminPubkeys.value = view.adminPubkeys
chatroom.relays.value = view.relays
chatroom.image.value = view.image
}
val previousCount = chatroom.members.value.size
val members = memberPubkeys(nostrGroupId)
chatroom.members.value = members
@@ -23,6 +23,7 @@ package com.vitorpamplona.quartz.marmot.appComponents
import com.vitorpamplona.quartz.marmot.mls.codec.TlsReader
import com.vitorpamplona.quartz.marmot.mls.codec.TlsWriter
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
/**
@@ -119,6 +120,9 @@ data class AdminPolicyV1(
return AdminPolicyV1(unique)
}
/** Build from hex account keys, sorting and de-duplicating. */
fun ofHex(keys: Collection<HexKey>): AdminPolicyV1 = of(keys.map { it.hexToByteArray() })
fun decode(bytes: ByteArray): AdminPolicyV1 {
val reader = TlsReader(bytes)
val flat = reader.readOpaqueVarInt()
@@ -439,6 +439,29 @@ class MlsGroupManager(
}
}
/**
* Stage an `app_data_update` proposal + Commit for one component.
*
* The current profile's carrier for group metadata. A GroupContextExtensions
* change rewrites the WHOLE extension set, which is what MIP-01 had to do
* with its single monolithic blob; `app_data_update` names one component
* id, so two admins changing different components do not clobber each
* other's work just by racing.
*
* Passing null [data] removes the component.
*/
suspend fun stageAppDataUpdate(
nostrGroupId: HexKey,
componentId: Int,
data: ByteArray?,
): StagedCommit {
requireAdminForExtensionChange(requireGroup(nostrGroupId))
return stage(nostrGroupId) { clone ->
if (data == null) clone.proposeAppDataRemoval(componentId) else clone.proposeAppDataUpdate(componentId, data)
clone.commit()
}
}
/** Stage a self-update / empty Commit. See [StagedCommit]. */
suspend fun stageCommit(nostrGroupId: HexKey): StagedCommit = stage(nostrGroupId) { it.commit() }