mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-05 19:28:25 +00:00
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:
@@ -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())
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
+25
-29
@@ -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() {
|
||||
|
||||
+1
-1
@@ -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 =
|
||||
|
||||
+10
@@ -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
|
||||
|
||||
+4
-5
@@ -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
|
||||
}
|
||||
|
||||
+40
-26
@@ -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
|
||||
}
|
||||
|
||||
+154
-38
@@ -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
|
||||
|
||||
+4
@@ -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()
|
||||
|
||||
+23
@@ -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() }
|
||||
|
||||
|
||||
Reference in New Issue
Block a user