feat(cli): amy group set-image/clear-image + whitenoise interop regression test

Add `amy marmot group set-image <gid> <file> [--server URL]` and
`amy marmot group clear-image <gid>`: encrypt the avatar with the MIP-01 v2 scheme
(interoperable with mdk/whitenoise), optionally push the ciphertext to Blossom signed
by the keypair derived from image_upload_key, and commit the image fields into the
group's NostrGroupData extension. Thin assembly over quartz
(MarmotGroupImageEncryption) + commons (updateGroupMetadata) per the CLI rules.

Add headless interop test_17_group_image_commit: A (amy) sets a group image on a group
whitenoise is a member of, then renames. The rename is a later MLS epoch, so wn can
only observe the new name if it first applied the image-bearing GCE commit — proving
the image extension stays parseable on mdk (which rejects trailing bytes). This is the
end-to-end guard for the group-icon interop fix.

Note: the marmot interop harness currently can't build current whitenoise-rs — its
patches target the pre-restructure `src/bin/wnd.rs` layout, but whitenoise-rs has moved
to a `crates/whitenoise-cli/` workspace. Refreshing those patches is separate harness
maintenance; the interop correctness is otherwise covered by the pinned-mdk source
analysis and the MarmotGroupImageTest wire regression test.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JL3GXW1fmHa3xWfQjLLqfp
This commit is contained in:
Claude
2026-07-15 01:07:00 +00:00
parent de2e71dad0
commit 22582d6a97
4 changed files with 115 additions and 3 deletions
@@ -41,6 +41,8 @@ object GroupCommands {
"rename" to { rest -> GroupMetadataCommands.rename(dataDir, rest) },
"promote" to { rest -> GroupMetadataCommands.promote(dataDir, rest) },
"demote" to { rest -> GroupMetadataCommands.demote(dataDir, rest) },
"set-image" to { rest -> GroupMetadataCommands.setImage(dataDir, rest) },
"clear-image" to { rest -> GroupMetadataCommands.clearImage(dataDir, rest) },
"remove" to { rest -> GroupMembershipCommands.remove(dataDir, rest) },
"leave" to { rest -> GroupMembershipCommands.leave(dataDir, rest) },
),
@@ -20,15 +20,22 @@
*/
package com.vitorpamplona.amethyst.cli.commands
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.service.upload.BlossomAuth
import com.vitorpamplona.amethyst.commons.service.upload.BlossomClient
import com.vitorpamplona.quartz.marmot.mip01Groups.MarmotGroupData
import com.vitorpamplona.quartz.marmot.mip01Groups.MarmotGroupImageEncryption
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
import java.io.File
/**
* Metadata-only commits: rename, promote/demote. Each loads current metadata,
* edits the right field, publishes a GCE commit to the group relays.
* Metadata-only commits: rename, promote/demote, set/clear image. Each loads current
* metadata, edits the right field, publishes a GCE commit to the group relays.
*/
object GroupMetadataCommands {
suspend fun rename(
@@ -64,9 +71,65 @@ object GroupMetadataCommands {
}
}
/**
* Set the group avatar (MIP-01 v2): encrypt the image, optionally push the
* ciphertext to Blossom (`--server`), and commit the image fields into the group
* metadata. The encryption is byte-for-byte interoperable with mdk/whitenoise.
*
* `group set-image <gid> <image-file> [--server URL] [--mime TYPE]`
*/
suspend fun setImage(
dataDir: DataDir,
rest: Array<String>,
): Int {
val args = Args(rest)
val gid = args.positional(0, "gid")
val path = args.positional(1, "image-file")
val server = args.flag("server")
val file = File(path)
if (!file.isFile) return Output.error("bad_args", "no such file: $path")
val plaintext = file.readBytes()
val enc = MarmotGroupImageEncryption.encrypt(plaintext)
val uploadKeySeed = MarmotGroupImageEncryption.generateUploadKey()
// Optionally push the encrypted blob to Blossom, signed by the keypair derived
// from image_upload_key so an admin holding the seed can later replace/delete it.
var uploadedUrl: String? = null
if (server != null) {
val uploadSigner = NostrSignerInternal(KeyPair(privKey = MarmotGroupImageEncryption.deriveUploadKeypairSecret(uploadKeySeed)))
val tmp = File.createTempFile("marmot-icon", ".bin")
try {
tmp.writeBytes(enc.ciphertext)
val auth = BlossomAuth.createUploadAuth(enc.imageHash, enc.ciphertext.size.toLong(), "Group image", uploadSigner)
val result = BlossomClient().upload(tmp, "application/octet-stream", server, auth)
if (result.sha256 != null && result.sha256 != enc.imageHash) {
return Output.error("hash_mismatch", "blossom returned ${result.sha256}, expected ${enc.imageHash}")
}
uploadedUrl = result.url
} finally {
tmp.delete()
}
}
return edit(dataDir, gid, mapOf("image_hash" to enc.imageHash, "image_url" to uploadedUrl)) { _, cur ->
cur.withImage(enc.imageHash, enc.imageKey, enc.imageNonce, uploadKeySeed)
}
}
/** Remove the group avatar. `group clear-image <gid>` */
suspend fun clearImage(
dataDir: DataDir,
rest: Array<String>,
): Int {
if (rest.isEmpty()) return Output.error("bad_args", "group clear-image <gid>")
return edit(dataDir, rest[0]) { _, cur -> cur.withoutImage() }
}
private suspend fun edit(
dataDir: DataDir,
rawGid: HexKey,
extra: Map<String, Any?> = emptyMap(),
mutate: suspend (Context, MarmotGroupData) -> MarmotGroupData,
): Int {
Context.open(dataDir).use { ctx ->
@@ -96,7 +159,7 @@ object GroupMetadataCommands {
"epoch" to ctx.marmot.groupEpoch(gid),
"commit_event_id" to commit.signedEvent.id,
"published_to" to ack.filterValues { it }.keys.map { it.url },
),
) + extra,
)
return 0
}
@@ -128,6 +128,7 @@ test_05_b_adds_a_existing
test_06_member_removal
test_07_metadata_rename
test_08_admin_promote_demote
test_17_group_image_commit
test_09_reply_react_unreact
test_10_concurrent_commits
test_11_leave_group
+46
View File
@@ -189,3 +189,49 @@ test_11_leave_group() {
record_result "$id" fail "A still in B's member list after leave"
fi
}
# Regression guard for the Marmot group-icon feature: setting a group image
# writes the MIP-01 image fields into the NostrGroupData extension. mdk-core (the
# library whitenoise uses) rejects ANY trailing bytes in that extension at a known
# version, so a mis-encoded image commit would make the whole group unprocessable
# for wn. We verify end-to-end that an amy-authored image commit stays parseable:
# A sets an image, then renames — the rename is a LATER epoch, so wn can only
# observe the new name if it first applied the image-bearing GCE commit. If the
# image extension were rejected, wn would stall at the pre-image epoch and never
# see the rename.
test_17_group_image_commit() {
banner "Test 17 — Group image commit stays parseable on whitenoise (MIP-01 v2)"
local id="17 group image"
local gid mls_gid
gid=$(load_state GROUP_02 || true)
mls_gid=$(load_state GROUP_02_MLS || true)
if [[ -z "${gid:-}" || -z "${mls_gid:-}" ]]; then
record_result "$id" skip "no GROUP_02"; return
fi
# Contents are irrelevant — amy encrypts whatever bytes it's given; the interop
# question is purely whether the resulting image extension parses on wn.
local img="$STATE_DIR/marmot-icon.bin"
head -c 1024 /dev/urandom >"$img" 2>/dev/null || printf 'fake-avatar-bytes-for-interop' >"$img"
if ! amy_json marmot group set-image "$gid" "$img" >/dev/null; then
record_result "$id" fail "amy set-image failed"; return
fi
sleep 3
if ! amy_json marmot group rename "$gid" "Interop-02-iconecho" >/dev/null; then
record_result "$id" fail "amy rename after set-image failed"; return
fi
local deadline=$(( $(date +%s) + 120 )) seen=""
while [[ $(date +%s) -lt $deadline ]]; do
seen=$(wn_b --json groups show "$mls_gid" 2>/dev/null | jq -r '(.result // .) | (.group // .) | .name // empty')
[[ "$seen" == "Interop-02-iconecho" ]] && break
sleep 3
done
if [[ "$seen" == "Interop-02-iconecho" ]]; then
record_result "$id" pass
else
record_result "$id" fail "wn did not advance past the image commit (name=\"$seen\")"
fi
}