feat(blossom): full-client protocol support across quartz, commons, CLI and Android

Extends Blossom support toward a full client on both the CLI and the mobile app.

Quartz (protocol):
- BlossomAuthorizationEvent: add t=media auth (BUD-05) and optional BUD-11
  `server` domain scoping on every factory (stops replayable upload/delete tokens)
- BlossomServerUrl: mirror/media/list/report path builders, BUD-06 preflight and
  BUD-07 payment header constants, and a lowercase bare-domain helper
- BlossomUploadResult: parse `ox` (BUD-05 original hash) and `nip94` (BUD-08)
- BlossomPaymentRequired: BUD-07 402 challenge model (Cashu/Lightning)
- BlossomReport: BUD-09 kind-1984 blob report reusing NIP-56 tag builders

Commons (shared JVM client, now in jvmAndroid so Android shares it too):
- BlossomClient gains mirror (BUD-04), list/delete (BUD-02), media (BUD-05),
  preflight/has (BUD-06/01), report (BUD-09) and typed 402 handling
- BlossomAuth: media/list/delete passthroughs with server scoping

CLI (first-class):
- amy blossom now routes all HTTP through the shared client and adds `media`
  and `report` verbs; auth tokens are scoped to --server

Android (first-class):
- uploads mirror to the user's other Blossom servers (BUD-04) best-effort
- new "Manage stored files" screen: per-server presence matrix (BUD-02 list +
  BUD-01 HEAD), delete, mirror-to-missing, and report actions

Tests: quartz URL/auth/descriptor/payment parsing.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ckbnz1N94W1hnNC9xpsCNP
This commit is contained in:
Claude
2026-07-17 23:36:41 +00:00
parent cd5060e5dc
commit bb03cd2a3c
21 changed files with 1557 additions and 254 deletions
@@ -0,0 +1,102 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nipB7Blossom
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
import kotlinx.coroutines.test.runTest
import kotlin.io.encoding.Base64
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
class BlossomAuthorizationEventTest {
private val signer = NostrSignerInternal(KeyPair())
private val hash = "b1674191a88ec5cdd733e4240a81803105dc412d6c6708d53ab94fc248f4f553"
@Test
fun uploadAuthHasRequiredTags() =
runTest {
val event = BlossomAuthorizationEvent.createUploadAuth(hash, 184292, "Uploading cat.png", signer)
assertEquals(BlossomAuthorizationEvent.KIND, event.kind)
assertEquals("upload", event.tags.first { it[0] == "t" }[1])
assertEquals(hash, event.tags.first { it[0] == "x" }[1])
assertEquals("184292", event.tags.first { it[0] == "size" }[1])
// NIP-40 expiration must be in the future.
val expiration = event.tags.first { it[0] == "expiration" }[1].toLong()
assertTrue(expiration > event.createdAt)
}
@Test
fun mediaAuthUsesMediaVerb() =
runTest {
val event = BlossomAuthorizationEvent.createMediaAuth(hash, 100, "Optimizing", signer)
assertEquals("media", event.tags.first { it[0] == "t" }[1])
}
@Test
fun serverScopeEmitsLowercaseBareDomainTags() =
runTest {
val event =
BlossomAuthorizationEvent.createDeleteAuth(
hash,
"Delete blob",
signer,
servers = listOf("https://CDN.Example.com/", "https://blossom.band:443/upload"),
)
val serverTags = event.tags.filter { it[0] == "server" }.map { it[1] }
assertEquals(listOf("cdn.example.com", "blossom.band"), serverTags)
}
@Test
fun deduplicatesServerScopeByDomain() =
runTest {
val event =
BlossomAuthorizationEvent.createUploadAuth(
hash,
1,
"Upload",
signer,
servers = listOf("https://cdn.example.com/a", "https://cdn.example.com/b"),
)
assertEquals(1, event.tags.count { it[0] == "server" })
}
@Test
fun noServerScopeWhenListEmpty() =
runTest {
val event = BlossomAuthorizationEvent.createUploadAuth(hash, 1, "Upload", signer)
assertTrue(event.tags.none { it[0] == "server" })
}
@Test
fun authorizationHeaderIsNostrPrefixedBase64OfTheEvent() =
runTest {
val event = BlossomAuthorizationEvent.createListAuth(signer, "List blobs")
val header = event.toAuthorizationHeader()
assertTrue(header.startsWith(BlossomAuthorizationEvent.AUTH_HEADER_SCHEME))
val decoded = Base64.decode(header.removePrefix(BlossomAuthorizationEvent.AUTH_HEADER_SCHEME)).decodeToString()
assertEquals(event.toJson(), decoded)
}
}
@@ -0,0 +1,59 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nipB7Blossom
import kotlin.test.Test
import kotlin.test.assertEquals
class BlossomServerUrlTest {
private val sha256 = "b1674191a88ec5cdd733e4240a81803105dc412d6c6708d53ab94fc248f4f553"
private val pubkey = "a8f3721a0dc1b4d5c12f4cc7c54ae14071eb9c1b4f9b2cf0d4ab22c0e9f0c7e0"
@Test
fun buildsEndpointPaths() {
assertEquals("https://cdn.example.com/upload", BlossomServerUrl.upload("https://cdn.example.com"))
assertEquals("https://cdn.example.com/mirror", BlossomServerUrl.mirror("https://cdn.example.com"))
assertEquals("https://cdn.example.com/media", BlossomServerUrl.media("https://cdn.example.com"))
assertEquals("https://cdn.example.com/report", BlossomServerUrl.report("https://cdn.example.com"))
assertEquals("https://cdn.example.com/list/$pubkey", BlossomServerUrl.list("https://cdn.example.com", pubkey))
}
@Test
fun collapsesTrailingSlash() {
assertEquals("https://cdn.example.com/upload", BlossomServerUrl.upload("https://cdn.example.com/"))
assertEquals("https://cdn.example.com/mirror", BlossomServerUrl.mirror("https://cdn.example.com/"))
assertEquals("https://cdn.example.com/list/$pubkey", BlossomServerUrl.list("https://cdn.example.com/", pubkey))
}
@Test
fun buildsBlobUrlWithOptionalExtension() {
assertEquals("https://cdn.example.com/$sha256", BlossomServerUrl.blob("https://cdn.example.com", sha256))
assertEquals("https://cdn.example.com/$sha256.png", BlossomServerUrl.blob("https://cdn.example.com", sha256, "png"))
}
@Test
fun extractsLowercaseBareDomainForServerScope() {
assertEquals("cdn.example.com", BlossomServerUrl.domain("https://cdn.example.com"))
assertEquals("cdn.example.com", BlossomServerUrl.domain("https://CDN.Example.com/"))
assertEquals("cdn.example.com", BlossomServerUrl.domain("https://cdn.example.com:8443/upload"))
assertEquals("blossom.band", BlossomServerUrl.domain("https://blossom.band"))
}
}
@@ -0,0 +1,108 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nipB7Blossom
import com.vitorpamplona.quartz.nip01Core.core.JsonMapper
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNull
class BlossomUploadResultTest {
@Test
fun parsesMinimalDescriptor() {
val json =
"""
{
"url": "https://cdn.example.com/b167.png",
"sha256": "b167",
"size": 184292,
"type": "image/png",
"uploaded": 1725105921
}
""".trimIndent()
val result = JsonMapper.fromJson<BlossomUploadResult>(json)
assertEquals("https://cdn.example.com/b167.png", result.url)
assertEquals("b167", result.sha256)
assertEquals(184292, result.size)
assertEquals("image/png", result.type)
assertNull(result.ox)
assertNull(result.nip94)
}
@Test
fun parsesMediaDescriptorWithOriginalHashAndNip94() {
// BUD-05 /media returns the optimized blob's hash in `sha256` and the
// original in `ox`; BUD-08 adds the `nip94` tag array.
val json =
"""
{
"url": "https://cdn.example.com/opt.png",
"sha256": "optimizedhash",
"ox": "originalhash",
"size": 123,
"type": "image/png",
"uploaded": 1725105921,
"nip94": [
["url", "https://cdn.example.com/opt.png"],
["m", "image/png"],
["x", "optimizedhash"],
["size", "123"]
]
}
""".trimIndent()
val result = JsonMapper.fromJson<BlossomUploadResult>(json)
assertEquals("optimizedhash", result.sha256)
assertEquals("originalhash", result.ox)
assertEquals(4, result.nip94?.size)
assertEquals(listOf("m", "image/png"), result.nip94?.get(1))
}
@Test
fun ignoresUnknownFields() {
val json = """{"url":"https://x/y","sha256":"a","serverSpecific":{"foo":1},"extra":"z"}"""
val result = JsonMapper.fromJson<BlossomUploadResult>(json)
assertEquals("a", result.sha256)
}
@Test
fun readsBud07PaymentHeaders() {
val headers =
mapOf(
BlossomServerUrl.X_CASHU_HEADER to "\"cashuBToken...\"",
BlossomServerUrl.X_LIGHTNING_HEADER to "lnbc10n1...",
BlossomServerUrl.REASON_HEADER to "Payment required: 10 sats",
)
val payment = BlossomPaymentRequired.fromHeaders { headers[it] }
assertEquals("cashuBToken...", payment.cashu)
assertEquals("lnbc10n1...", payment.lightning)
assertEquals("Payment required: 10 sats", payment.reason)
assertEquals(true, payment.hasPaymentOption())
}
@Test
fun paymentWithNoMethodsIsNotPayable() {
val payment = BlossomPaymentRequired.fromHeaders { null }
assertEquals(false, payment.hasPaymentOption())
}
}