mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-05 19:28:25 +00:00
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:
@@ -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) },
|
||||
),
|
||||
|
||||
+66
-3
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user