diff --git a/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/service/images/HdrGainmapPipelineInstrumentedTest.kt b/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/service/images/HdrGainmapPipelineInstrumentedTest.kt new file mode 100644 index 0000000000..cd01f55818 --- /dev/null +++ b/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/service/images/HdrGainmapPipelineInstrumentedTest.kt @@ -0,0 +1,257 @@ +/* + * 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.amethyst.service.images + +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.graphics.Color +import android.graphics.Gainmap +import android.os.Build +import androidx.core.graphics.createBitmap +import androidx.core.net.toUri +import androidx.exifinterface.media.ExifInterface +import androidx.test.ext.junit.runners.AndroidJUnit4 +import coil3.BitmapImage +import coil3.ImageLoader +import coil3.annotation.ExperimentalCoilApi +import coil3.decode.DataSource +import coil3.disk.DiskCache +import coil3.memory.MemoryCache +import coil3.network.DeDupeConcurrentRequestStrategy +import coil3.request.ErrorResult +import coil3.request.ImageRequest +import coil3.request.SuccessResult +import coil3.request.allowHardware +import coil3.size.Precision +import com.vitorpamplona.amethyst.AvifInstrumentedTestSupport.appContext +import com.vitorpamplona.amethyst.commons.service.uploads.CompressorQuality +import com.vitorpamplona.amethyst.service.uploads.MediaCompressor +import com.vitorpamplona.amethyst.service.uploads.MetadataStripper +import kotlinx.coroutines.runBlocking +import okhttp3.OkHttpClient +import okio.Path.Companion.toOkioPath +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Assume.assumeTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import java.io.File +import java.net.InetAddress +import java.net.ServerSocket +import java.util.UUID +import kotlin.concurrent.thread + +/** + * Walks an Ultra HDR JPEG (a JPEG with an ISO 21496-1 / Android gain map) through every stage an + * uploaded or viewed photo passes, asserting the gain map survives each one. Losing it anywhere + * turns the photo into plain SDR, which is what "HDR photos don't work" looks like to a user. + */ +@RunWith(AndroidJUnit4::class) +class HdrGainmapPipelineInstrumentedTest { + private lateinit var hdrJpeg: File + + @Before + fun setUp() { + assumeTrue("Gain maps need API 34+", Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) + hdrJpeg = File(appContext.cacheDir.also { it.mkdirs() }, "${UUID.randomUUID()}.jpg") + val fixture = ultraHdrBitmap() + try { + hdrJpeg.outputStream().use { fixture.compress(Bitmap.CompressFormat.JPEG, 95, it) } + } finally { + fixture.recycleWithGainmap() + } + } + + @After + fun tearDown() { + if (::hdrJpeg.isInitialized) hdrJpeg.delete() + } + + @Test + fun theFixtureIsAnUltraHdrJpeg() { + assertTrue("fixture lost its gain map", decodesWithGainmap(hdrJpeg)) + } + + @Test + fun metadataStrippingKeepsTheGainmap() { + val stripped = MetadataStripper.stripImageMetadata(hdrJpeg.toUri(), appContext) + assertTrue("stripper did not run", stripped.stripped) + assertTrue("stripping dropped the gain map", decodesWithGainmap(File(requireNotNull(stripped.uri.path)))) + } + + @Test + fun compressionKeepsTheGainmap() = + runBlocking { + val out = compress(hdrJpeg) + val bounds = bounds(out) + // 4000x3000 against the compressor's 640x816 target: BitmapFactory subsamples by 2. + assertEquals("the compressor did not downscale", 2000, bounds.outWidth) + assertTrue("compression dropped the gain map", decodesWithGainmap(out)) + } + + @Test + fun compressionOfARotatedPhotoKeepsTheGainmap() = + runBlocking { + // Portrait phone photos are stored landscape plus an EXIF rotation, which the + // compressor bakes in through Bitmap.createBitmap(..., matrix, ...). + ExifInterface(hdrJpeg).apply { + setAttribute(ExifInterface.TAG_ORIENTATION, ExifInterface.ORIENTATION_ROTATE_90.toString()) + saveAttributes() + } + val out = compress(hdrJpeg) + val bounds = bounds(out) + assertTrue("the compressor did not rotate", bounds.outHeight > bounds.outWidth) + assertTrue("rotating dropped the gain map", decodesWithGainmap(out)) + } + + @Test + fun coilDecodeKeepsTheGainmap() = + runBlocking { + // Coil owns the bitmaps it returns (its memory cache may hand them out again), so + // release them by shutting the loader down rather than recycling them here. + val loader = ImageLoader.Builder(appContext).build() + try { + for (hardware in listOf(true, false)) { + val request = + ImageRequest + .Builder(appContext) + .data(hdrJpeg) + .size(400, 300) + .allowHardware(hardware) + .build() + val result = loader.execute(request) as SuccessResult + val bitmap = (result.image as BitmapImage).bitmap + assertTrue("Coil dropped the gain map (allowHardware=$hardware)", bitmap.hasGainmap()) + } + } finally { + loader.shutdown() + } + } + + /** + * A feed image as the app loads it: over HTTP through [OkHttpFactory] (which re-homes the + * disk-cache file so the platform ImageDecoder is reachable), then again from the memory + * cache, then from the disk cache after the memory cache is gone. + */ + @OptIn(ExperimentalCoilApi::class) + @Test + fun coilCachesKeepTheGainmap() = + runBlocking { + val cacheDir = File(appContext.cacheDir, "hdr-coil-${UUID.randomUUID()}") + val bytes = hdrJpeg.readBytes() + ServerSocket(0, 50, InetAddress.getByName("127.0.0.1")).use { server -> + serveForever(server, bytes) + val memoryCache = MemoryCache.Builder().maxSizeBytes(64L * 1024 * 1024).build() + val loader = + ImageLoader + .Builder(appContext) + .memoryCache(memoryCache) + .diskCache(DiskCache.Builder().directory(cacheDir.toOkioPath()).build()) + .precision(Precision.INEXACT) + .components { + val client = OkHttpClient() + add(OkHttpFactory({ client }, DeDupeConcurrentRequestStrategy())) + }.build() + val url = "http://127.0.0.1:${server.localPort}/hdr.jpg" + val request = + ImageRequest + .Builder(appContext) + .data(url) + .size(400, 300) + .build() + + suspend fun load(expected: DataSource) { + val result = loader.execute(request) + if (result is ErrorResult) throw AssertionError("$expected load failed", result.throwable) + result as SuccessResult + assertEquals(expected, result.dataSource) + assertTrue("gain map lost on a $expected load", (result.image as BitmapImage).bitmap.hasGainmap()) + } + + try { + load(DataSource.NETWORK) + load(DataSource.MEMORY_CACHE) + memoryCache.clear() + load(DataSource.DISK) + } finally { + loader.shutdown() + } + } + cacheDir.deleteRecursively() + } + + private suspend fun compress(file: File): File { + val compressed = MediaCompressor().compress(file.toUri(), "image/jpeg", CompressorQuality.MEDIUM, appContext) + assertEquals("the compressor fell back to the original", "image/jpeg", compressed.contentType) + val out = File(requireNotNull(compressed.uri.path)) + assertTrue("the compressor returned the original file", out.absolutePath != file.absolutePath) + return out + } + + private fun bounds(file: File) = BitmapFactory.Options().apply { inJustDecodeBounds = true }.also { BitmapFactory.decodeFile(file.absolutePath, it) } + + // Full-size decodes of a 4000x3000 photo: recycle them rather than wait on the GC between tests. + private fun decodesWithGainmap(file: File): Boolean { + val bitmap = BitmapFactory.decodeFile(file.absolutePath) ?: return false + return try { + bitmap.hasGainmap() + } finally { + bitmap.recycleWithGainmap() + } + } + + // The gain map is a separate bitmap; recycling the base does not free it. + private fun Bitmap.recycleWithGainmap() { + gainmap?.gainmapContents?.recycle() + recycle() + } + + private fun serveForever( + server: ServerSocket, + body: ByteArray, + ) = thread(isDaemon = true) { + while (!server.isClosed) { + val socket = runCatching { server.accept() }.getOrNull() ?: break + socket.use { + val input = it.getInputStream().bufferedReader() + while (input.readLine()?.isNotEmpty() == true) Unit + it.getOutputStream().apply { + write("HTTP/1.1 200 OK\r\nContent-Type: image/jpeg\r\nContent-Length: ${body.size}\r\nConnection: close\r\n\r\n".toByteArray()) + write(body) + flush() + } + } + } + } + + private fun ultraHdrBitmap(): Bitmap { + val base = createBitmap(4000, 3000).apply { eraseColor(Color.rgb(120, 140, 160)) } + val contents = createBitmap(1000, 750).apply { eraseColor(Color.rgb(200, 200, 200)) } + base.gainmap = + Gainmap(contents).apply { + setRatioMax(4f, 4f, 4f) + setDisplayRatioForFullHdr(4f) + } + return base + } +} diff --git a/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/ui/components/HdrWindowModeTest.kt b/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/ui/components/HdrWindowModeTest.kt new file mode 100644 index 0000000000..4c94193825 --- /dev/null +++ b/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/ui/components/HdrWindowModeTest.kt @@ -0,0 +1,144 @@ +/* + * 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.amethyst.ui.components + +import android.content.pm.ActivityInfo +import android.graphics.Gainmap +import android.os.Build +import android.view.Window +import android.view.WindowManager +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.test.junit4.v2.createComposeRule +import androidx.compose.ui.window.Dialog +import androidx.core.graphics.createBitmap +import androidx.test.ext.junit.runners.AndroidJUnit4 +import coil3.asImage +import com.vitorpamplona.amethyst.commons.ui.components.getActivityWindow +import com.vitorpamplona.amethyst.commons.ui.components.getDialogWindow +import org.junit.Assert.assertEquals +import org.junit.Assume.assumeTrue +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class HdrWindowModeTest { + @get:Rule + val rule = createComposeRule() + + @Before + fun requireGainmaps() { + assumeTrue("Gain maps need API 34+", Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) + } + + private fun hdrImage() = createBitmap(8, 8).apply { gainmap = Gainmap(createBitmap(2, 2)) }.asImage() + + private fun sdrImage() = createBitmap(8, 8).asImage() + + @Test + fun theWindowIsHdrOnlyWhileAGainmapImageIsShown() { + var window: Window? = null + var showFirst by mutableStateOf(true) + var showSecond by mutableStateOf(true) + val image = hdrImage() + + rule.setContent { + window = getActivityWindow() + if (showFirst) RequestHdrFor(image, fullscreen = false) + if (showSecond) RequestHdrFor(image, fullscreen = false) + } + rule.waitForIdle() + assertEquals(ActivityInfo.COLOR_MODE_HDR, window!!.colorMode) + + showFirst = false + rule.waitForIdle() + assertEquals("one image still wants HDR", ActivityInfo.COLOR_MODE_HDR, window!!.colorMode) + + showSecond = false + rule.waitForIdle() + assertEquals(ActivityInfo.COLOR_MODE_DEFAULT, window!!.colorMode) + } + + @Test + fun anSdrImageLeavesTheWindowAlone() { + var window: Window? = null + rule.setContent { + window = getActivityWindow() + RequestHdrFor(sdrImage(), fullscreen = false) + } + rule.waitForIdle() + assertEquals(ActivityInfo.COLOR_MODE_DEFAULT, window!!.colorMode) + } + + @Test + fun insideADialogTheDialogWindowGoesHdr() { + var activityWindow: Window? = null + var dialogWindow: Window? = null + val image = hdrImage() + + rule.setContent { + activityWindow = getActivityWindow() + Dialog(onDismissRequest = {}) { + dialogWindow = getDialogWindow() + RequestHdrFor(image, fullscreen = true) + } + } + rule.waitForIdle() + assertEquals(ActivityInfo.COLOR_MODE_HDR, dialogWindow!!.colorMode) + assertEquals(ActivityInfo.COLOR_MODE_DEFAULT, activityWindow!!.colorMode) + } + + @Test + fun moreHdrImagesDoNotResendTheWindowAttributes() { + var window: Window? = null + var count by mutableStateOf(1) + val image = hdrImage() + var attributeChanges = 0 + + rule.setContent { + window = getActivityWindow() + repeat(count) { RequestHdrFor(image, fullscreen = false) } + } + rule.waitForIdle() + rule.runOnUiThread { + val original = window!!.callback + window!!.callback = + object : Window.Callback by original { + override fun onWindowAttributesChanged(attrs: WindowManager.LayoutParams?) { + attributeChanges++ + original.onWindowAttributesChanged(attrs) + } + } + } + + // Cards scrolling in and out while HDR is already on: nothing for the window to change. + count = 4 + rule.waitForIdle() + count = 2 + rule.waitForIdle() + + assertEquals(0, attributeChanges) + assertEquals(ActivityInfo.COLOR_MODE_HDR, window!!.colorMode) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/AuthCoordinator.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/AuthCoordinator.kt index 1f4356b4a3..eebf70e946 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/AuthCoordinator.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/AuthCoordinator.kt @@ -298,7 +298,7 @@ class AuthCoordinator( relayUrl: NormalizedRelayUrl, ): EventTemplate { if (!BuzzRelayDialect.isBuzz(relayUrl)) return template - val authTag = account.buzzAttestation.authTag() ?: return template + val authTag = account.buzzAttestation.authTag(template.createdAt) ?: return template return EventTemplate(template.createdAt, template.kind, template.tags + arrayOf(authTag), template.content) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/HdrWindowMode.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/HdrWindowMode.kt new file mode 100644 index 0000000000..bc44178bbf --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/HdrWindowMode.kt @@ -0,0 +1,114 @@ +/* + * 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.amethyst.ui.components + +import android.content.pm.ActivityInfo +import android.os.Build +import android.view.Window +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.remember +import coil3.BitmapImage +import coil3.Image +import com.vitorpamplona.amethyst.commons.ui.components.getActivityWindow +import com.vitorpamplona.amethyst.commons.ui.components.getDialogWindow +import java.util.WeakHashMap + +/** + * The images on one window that currently want HDR, and how much headroom each asks for. + * + * An Ultra HDR photo (a JPEG with a gain map) only renders brighter than SDR white while its + * window is in [ActivityInfo.COLOR_MODE_HDR]; otherwise the platform silently draws the SDR base + * image. The window is shared by every card in a feed, so HDR stays on until the last image + * asking for it leaves composition, and then the window gets its [originalColorMode] back. + */ +class HdrRequests( + val originalColorMode: Int, +) { + private val headrooms = mutableListOf() + + val wantsHdr: Boolean get() = headrooms.isNotEmpty() + + /** [UNCAPPED] if any request is uncapped or there are none, else the largest cap asked for. */ + val headroom: Float get() = if (UNCAPPED in headrooms) UNCAPPED else headrooms.maxOrNull() ?: UNCAPPED + + fun add(headroom: Float) { + headrooms.add(headroom) + } + + /** Drops one request for [headroom]; equal requests are interchangeable. */ + fun remove(headroom: Float) { + headrooms.remove(headroom) + } + + companion object { + /** `Window.setDesiredHdrHeadroom`'s "no preference": the display's full HDR range. */ + const val UNCAPPED = 0f + + /** + * Feed cards share the screen with SDR text and chrome, which full HDR brightness makes + * look dim and grey. Cap them; the fullscreen viewer asks for [UNCAPPED]. + */ + const val FEED_HEADROOM = 2f + } +} + +// Main-thread only: composition and disposal both run there. +private val windowRequests = WeakHashMap() + +// Each setter dispatches the window attributes to the window manager even when the value is +// unchanged, and cards scroll in and out of a feed constantly: only write what actually changed. +private fun Window.applyHdr(requests: HdrRequests) { + val mode = if (requests.wantsHdr) ActivityInfo.COLOR_MODE_HDR else requests.originalColorMode + if (colorMode != mode) colorMode = mode + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.VANILLA_ICE_CREAM) { + if (desiredHdrHeadroom != requests.headroom) desiredHdrHeadroom = requests.headroom + } +} + +fun Image.hasGainmap(): Boolean = Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE && (this as? BitmapImage)?.bitmap?.hasGainmap() == true + +/** + * Puts the hosting window (the dialog's own window inside a `Dialog`) into HDR mode while this + * is in composition and [image] carries a gain map, so an Ultra HDR photo shows its highlights. + * A [fullscreen] image gets the display's full HDR range; a feed card is capped. + */ +@Composable +fun RequestHdrFor( + image: Image, + fullscreen: Boolean, +) { + if (!remember(image) { image.hasGainmap() }) return + val window = getDialogWindow() ?: getActivityWindow() ?: return + val headroom = if (fullscreen) HdrRequests.UNCAPPED else HdrRequests.FEED_HEADROOM + + DisposableEffect(window, headroom) { + val requests = windowRequests.getOrPut(window) { HdrRequests(window.colorMode) } + requests.add(headroom) + window.applyHdr(requests) + + onDispose { + requests.remove(headroom) + window.applyHdr(requests) + if (!requests.wantsHdr) windowRequests.remove(window) + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentDialog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentDialog.kt index 53ecdf75f5..8e982bba44 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentDialog.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentDialog.kt @@ -21,6 +21,7 @@ package com.vitorpamplona.amethyst.ui.components import android.content.Context +import android.os.Build import android.os.Handler import android.os.Looper import android.view.WindowManager @@ -44,6 +45,7 @@ import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.MutableState import androidx.compose.runtime.getValue @@ -182,20 +184,29 @@ fun ZoomableImageDialog( val activityWindow = getActivityWindow() val dialogWindow = getDialogWindow() - if (activityWindow != null && dialogWindow != null) { - // Preserve any brightness override already applied to the dialog window (e.g. by the - // fullscreen swipe controls). This block re-runs on recomposition (orientation change - // re-reads `orientation` above), and copying the activity attributes would otherwise - // reset screenBrightness and snap the user's brightness back mid-session. - val currentBrightness = dialogWindow.attributes.screenBrightness - val attributes = WindowManager.LayoutParams() - attributes.copyFrom(activityWindow.attributes) - attributes.type = dialogWindow.attributes.type - // Disable the system dim so the thumbnail stays visible behind the growing dialog. - attributes.dimAmount = 0f - attributes.flags = attributes.flags and WindowManager.LayoutParams.FLAG_DIM_BEHIND.inv() - attributes.screenBrightness = currentBrightness - dialogWindow.attributes = attributes + // Keyed, not inline: each assignment below is a window-manager round trip, so it must run + // on orientation changes only, not on every recomposition of this content. + DisposableEffect(orientation, activityWindow, dialogWindow) { + if (activityWindow != null && dialogWindow != null) { + // Preserve what the dialog window owns: the brightness override applied by the + // fullscreen swipe controls, and the HDR mode [RequestHdrFor] manages for the + // image on screen. Copying the activity's instead would snap brightness back + // mid-session and cap or force HDR by whatever the feed behind happens to show. + val current = dialogWindow.attributes + val attributes = WindowManager.LayoutParams() + attributes.copyFrom(activityWindow.attributes) + attributes.type = current.type + // Disable the system dim so the thumbnail stays visible behind the growing dialog. + attributes.dimAmount = 0f + attributes.flags = attributes.flags and WindowManager.LayoutParams.FLAG_DIM_BEHIND.inv() + attributes.screenBrightness = current.screenBrightness + attributes.colorMode = current.colorMode + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.VANILLA_ICE_CREAM) { + attributes.desiredHdrHeadroom = current.desiredHdrHeadroom + } + dialogWindow.attributes = attributes + } + onDispose {} } // Go fully immersive while the full-screen media viewer is open. Applies to full-screen @@ -215,9 +226,9 @@ fun ZoomableImageDialog( allImages = allImages, imageUrl = imageUrl, sourceBounds = sourceBounds, - imageBounds = imageBounds, + imageBounds = { imageBounds }, onImageBoundsChanged = updateImageBounds, - currentZoomState = currentZoomState, + currentZoomState = { currentZoomState }, onZoomStateChanged = { currentZoomState = it }, progress = progressProvider, onDismiss = dismissWithAnimation, @@ -232,9 +243,9 @@ private fun DialogContent( allImages: ImmutableList, imageUrl: BaseMediaContent, sourceBounds: Rect?, - imageBounds: Rect?, + imageBounds: () -> Rect?, onImageBoundsChanged: (Rect) -> Unit, - currentZoomState: ZoomState?, + currentZoomState: () -> ZoomState?, onZoomStateChanged: (ZoomState) -> Unit, progress: () -> Float, onDismiss: () -> Unit, @@ -271,12 +282,12 @@ private fun DialogContent( .fillMaxSize() .graphicsLayer { val src = sourceBounds - val img = imageBounds + val img = imageBounds() if (src != null && img != null && src.hasArea() && img.hasArea()) { // Account for user-applied zoom: the exit animation must start from // the visible bounds, not the unzoomed layout bounds — otherwise // dismissing a zoomed-in image jumps. - val zoomed = img.zoomedBy(currentZoomState) + val zoomed = img.zoomedBy(currentZoomState()) // Uniform scale so non-square images keep their aspect ratio during // the grow animation. The image covers the source rect; the overflow // is clipped below. diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt index 4d70f3f355..40c99907e2 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt @@ -457,9 +457,11 @@ fun LocalImageView( is AsyncImagePainter.State.Success -> { SubcomposeAsyncImageContent(loadedImageModifier) + val image = (state as AsyncImagePainter.State.Success).result.image + RequestHdrFor(image, fullscreen = fullResolution) + SideEffect { - val drawable = (state as AsyncImagePainter.State.Success).result.image - MediaAspectRatioCache.add(content.localJavaFile.toString(), drawable.width, drawable.height) + MediaAspectRatioCache.add(content.localJavaFile.toString(), image.width, image.height) } content.isVerified?.let { @@ -599,9 +601,11 @@ fun UrlImageView( ShowHashAnimated(content, controllerVisible, Modifier.align(Alignment.TopEnd)) + val image = (state as AsyncImagePainter.State.Success).result.image + RequestHdrFor(image, fullscreen = fullResolution) + SideEffect { - val drawable = (state as AsyncImagePainter.State.Success).result.image - MediaAspectRatioCache.add(content.url, drawable.width, drawable.height) + MediaAspectRatioCache.add(content.url, image.width, image.height) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatMessageActionSheet.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatMessageActionSheet.kt index 42db67fdba..105cbca892 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatMessageActionSheet.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatMessageActionSheet.kt @@ -61,7 +61,9 @@ import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.commons.model.Note import com.vitorpamplona.amethyst.commons.model.User +import com.vitorpamplona.amethyst.commons.model.buzz.BuzzRelayDialect import com.vitorpamplona.amethyst.commons.model.concord.ConcordChannel +import com.vitorpamplona.amethyst.commons.model.isBuzzEditableBy import com.vitorpamplona.amethyst.commons.model.navigation.Route import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel import com.vitorpamplona.amethyst.commons.relayClient.reqCommand.channel.observeChannel @@ -283,13 +285,25 @@ fun ChatMessageActionSheet( ChatOnlyRow(note, state, onWantsToReply, onWantsToEditDraft, onDismiss) // Editing my own chat message. Two surfaces publish an edit today, gated by type: - // - Buzz: kind-40002 stream message → a kind-40003 edit. + // - Buzz: kind-9 message in a Buzz-dialect relay group (or a legacy kind-40002 one) + // → a kind-40003 edit, by its author or the author's agent owner. // - Concord: kind-9 channel message or kind-1111 thread reply (carries a // ConcordChannel gatherer) → a kind-3302 edit wrapped on the channel plane. // Both restrict to my own messages; a note is only ever one of the two, so at // most one tile shows and both route through the same edit callback. val isMine = note.author?.pubkeyHex == accountViewModel.userProfile().pubkeyHex - val canEditBuzz = onWantsToEditChatMessage != null && note.event is StreamMessageV2Event && isMine + // Buzz lets the owner of an agent edit the agent's messages too, and credits a + // relay-signed message to the member it names, so its own rule decides. + val canEditBuzz = + onWantsToEditChatMessage != null && + ( + note.event is StreamMessageV2Event || + ( + note.event is ChatEvent && + note.inGatherers?.any { it is RelayGroupChannel && BuzzRelayDialect.isBuzz(it.groupId.relayUrl) } == true + ) + ) && + note.isBuzzEditableBy(accountViewModel.userProfile().pubkeyHex) val canEditConcord = onWantsToEditChatMessage != null && (note.event is ChatEvent || note.event is CommentEvent) && diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatMessageCompose.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatMessageCompose.kt index bb2cf0cb60..c34785a2c8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatMessageCompose.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatMessageCompose.kt @@ -126,7 +126,7 @@ fun ChatroomMessageCompose( // reply quotes inside a DM, where the target is simply older than the loaded window (see // LoadingReplyNote). Null keeps the default blank for every other caller. onBlank: (@Composable () -> Unit)? = null, - // Edit my own chat message on surfaces that support it (Buzz kind-40002 → 40003, + // Edit my own chat message on surfaces that support it (Buzz kind-9 or legacy 40002 → 40003, // Concord kind-9 → 1010). Null for chat surfaces without message editing, which hides // the action. onWantsToEditChatMessage: ((Note) -> Unit)? = null, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/DrawAuthorInfo.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/DrawAuthorInfo.kt index 12efb2524c..0b619808bb 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/DrawAuthorInfo.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/DrawAuthorInfo.kt @@ -20,6 +20,8 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue @@ -28,10 +30,15 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.commons.buzz.ui.BuzzAgentLabel +import com.vitorpamplona.amethyst.commons.buzz.ui.rememberBuzzContextualName import com.vitorpamplona.amethyst.commons.chats.ui.UserDisplayNameLayout import com.vitorpamplona.amethyst.commons.model.EmptyTagList import com.vitorpamplona.amethyst.commons.model.Note import com.vitorpamplona.amethyst.commons.model.User +import com.vitorpamplona.amethyst.commons.model.buzz.BuzzRelayDialect +import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel import com.vitorpamplona.amethyst.commons.relayClient.user.observeUserDisplayNickname import com.vitorpamplona.amethyst.commons.relayClient.user.observeUserInfo import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav @@ -44,6 +51,7 @@ import com.vitorpamplona.amethyst.commons.ui.theme.Size20dp import com.vitorpamplona.amethyst.commons.ui.theme.Size5Modifier import com.vitorpamplona.amethyst.commons.ui.theme.isLight import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel +import com.vitorpamplona.quartz.buzz.identityNames.IdentityNamePolicy @Composable fun DrawAuthorInfo( @@ -54,8 +62,15 @@ fun DrawAuthorInfo( // A geohash chat resolves the message's `n` nickname here (throwaway keys have no profile); // null everywhere else, so authors render from their profile as usual. val nameOverride = LocalChatDisplayNameResolver.current?.invoke(baseNote) + // In a Buzz channel two members may share a name; Buzz tells them apart there. + val buzzChannel = + remember(baseNote) { + baseNote.inGatherers + ?.firstNotNullOfOrNull { it as? RelayGroupChannel } + ?.takeIf { BuzzRelayDialect.isBuzz(it.groupId.relayUrl) } + } baseNote.author?.let { - WatchAndDisplayUser(it, nameOverride, accountViewModel, nav) + WatchAndDisplayUser(it, nameOverride, buzzChannel, accountViewModel, nav) } } @@ -80,14 +95,20 @@ fun authorNameColorFor( private fun WatchAndDisplayUser( author: User, nameOverride: String?, + buzzChannel: RelayGroupChannel?, accountViewModel: AccountViewModel, nav: INav, ) { val userState by observeUserInfo(author, accountViewModel) val nickname by observeUserDisplayNickname(author, accountViewModel) val petName = nickname?.petName - // A geohash message's `n` nickname wins over the (usually empty) profile of a throwaway key. - val displayName = nameOverride ?: petName ?: userState?.info?.bestName() + val profileName = userState?.info?.bestName() + // Buzz's contextual name ("Alice’s Honey", "Honey · 7xk2") when the channel has a namesake. + val contextual = buzzChannel?.let { rememberBuzzContextualName(it, author, accountViewModel) }?.name + val qualified = contextual != null && profileName != null && contextual != IdentityNamePolicy.trim(profileName) + // A geohash message's `n` nickname wins over the (usually empty) profile of a throwaway key, and a + // nickname the account set wins over everything else. + val displayName = nameOverride ?: petName ?: (if (qualified) contextual else profileName) val isLightTheme = MaterialTheme.colorScheme.isLight val nameColor = @@ -114,13 +135,22 @@ private fun WatchAndDisplayUser( ObserveAndRenderUserCards(author, Size20dp, Modifier.align(Alignment.BottomCenter), accountViewModel) }, name = { - CreateTextWithEmoji( - text = displayName ?: author.pubkeyDisplayHex(), - tags = (if (nameOverride == null && petName != null) nickname?.tags else userState?.tags) ?: EmptyTagList, - color = nameColor, - maxLines = 1, - fontWeight = FontWeight.Bold, - ) + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + CreateTextWithEmoji( + text = displayName ?: author.pubkeyDisplayHex(), + tags = (if (nameOverride == null && petName != null) nickname?.tags else userState?.tags) ?: EmptyTagList, + color = nameColor, + maxLines = 1, + fontWeight = FontWeight.Bold, + modifier = Modifier.weight(1f, fill = false), + ) + // An agent says so, and whose it is (its owner comes from the NIP-OA tag on its kind 0), + // unless its contextual name already does. + BuzzAgentLabel(author, userState?.nipOaOwner, accountViewModel, showOwner = !qualified) + } }, ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/types/RenderBuzzNotes.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/types/RenderBuzzNotes.kt index c8285ca31f..df4bfc6ab9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/types/RenderBuzzNotes.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/types/RenderBuzzNotes.kt @@ -73,6 +73,7 @@ import com.vitorpamplona.quartz.buzz.jobs.JobErrorEvent import com.vitorpamplona.quartz.buzz.jobs.JobProgressEvent import com.vitorpamplona.quartz.buzz.jobs.JobRequestEvent import com.vitorpamplona.quartz.buzz.jobs.JobResultEvent +import com.vitorpamplona.quartz.buzz.stream.BuzzEditTagOverlay import com.vitorpamplona.quartz.buzz.stream.StreamMessageDiffEvent import com.vitorpamplona.quartz.buzz.stream.StreamMessageV2Event import com.vitorpamplona.quartz.buzz.stream.SystemMessageEvent @@ -101,7 +102,15 @@ fun RenderBuzzEditedNote( RenderRegularTextNote(note, canPreview, innerQuote, bgColor, accountViewModel, nav) return } - val tags = remember(note.event) { note.event?.tags?.toImmutableListOfLists() ?: EmptyTagList } + // Render with the tags the edit leaves the message with (Buzz's applyEditTagOverlay): the edit's + // attachments and custom emoji, the original's channel/thread tags. + val tags = + remember(note.event, editNote.event) { + note.event + ?.tags + ?.let { BuzzEditTagOverlay.apply(it, editNote.event?.tags) } + ?.toImmutableListOfLists() ?: EmptyTagList + } Column { TranslatableRichTextViewer( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupMetadataViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupMetadataViewModel.kt index a5ded4051f..26c5cbba79 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupMetadataViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupMetadataViewModel.kt @@ -41,6 +41,8 @@ import com.vitorpamplona.amethyst.commons.ui.uploads.uploadToDefaultServer import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel import com.vitorpamplona.quartz.buzz.workspace.BUZZ_CHANNEL_TYPE_FORUM import com.vitorpamplona.quartz.buzz.workspace.BUZZ_CHANNEL_TYPE_STREAM +import com.vitorpamplona.quartz.buzz.workspace.canonicalBuzzChannelName +import com.vitorpamplona.quartz.buzz.workspace.isValidBuzzChannelName import com.vitorpamplona.quartz.buzz.workspace.newBuzzChannelId import com.vitorpamplona.quartz.nip01Core.core.toHexKey import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl @@ -127,7 +129,13 @@ class RelayGroupMetadataViewModel : ViewModel() { var touched by mutableStateOf(false) private set - val canPost by derivedStateOf { !isWorking && name.value.text.isNotBlank() } + /** + * A Buzz relay strips leading `#`s and whitespace from a channel name and refuses one that ends + * up empty, so on Buzz a name like `"# #"` can't be submitted at all. + */ + val canPost by derivedStateOf { + !isWorking && name.value.text.isNotBlank() && (!isBuzzRelay || isValidBuzzChannelName(name.value.text)) + } fun hasImage(): Boolean = pickedMedia != null || picture.value.text.isNotBlank() @@ -239,7 +247,9 @@ class RelayGroupMetadataViewModel : ViewModel() { } private suspend fun publish() { - val name = name.value.text.trim() + // Send Buzz the name it would store anyway (no leading `#`s), so our optimistic copy and + // the relay's 39000 agree. + val name = if (isBuzzRelay) canonicalBuzzChannelName(name.value.text) else name.value.text.trim() val about = about.value.text .trim() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupTopBar.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupTopBar.kt index a70e5755f4..15d335c1f0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupTopBar.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupTopBar.kt @@ -40,7 +40,9 @@ import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.State +import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableLongStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue @@ -53,6 +55,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.commons.model.buzz.BuzzDmRegistry +import com.vitorpamplona.amethyst.commons.model.buzz.BuzzHuddleLivenessState import com.vitorpamplona.amethyst.commons.model.buzz.BuzzRelayDialect import com.vitorpamplona.amethyst.commons.model.buzz.BuzzWorkspaceStates import com.vitorpamplona.amethyst.commons.model.cache.LocalCache @@ -69,6 +72,7 @@ import com.vitorpamplona.amethyst.commons.resources.buzz_channel_archive import com.vitorpamplona.amethyst.commons.resources.buzz_channel_delete import com.vitorpamplona.amethyst.commons.resources.buzz_channel_delete_confirm import com.vitorpamplona.amethyst.commons.resources.buzz_channel_unarchive +import com.vitorpamplona.amethyst.commons.resources.buzz_huddle_live import com.vitorpamplona.amethyst.commons.resources.cancel import com.vitorpamplona.amethyst.commons.resources.join import com.vitorpamplona.amethyst.commons.resources.leave @@ -97,6 +101,10 @@ import com.vitorpamplona.quartz.buzz.workspace.buzzParticipants import com.vitorpamplona.quartz.buzz.workspace.isBuzzDm import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.relay.normalizer.displayUrl +import com.vitorpamplona.quartz.utils.Log +import com.vitorpamplona.quartz.utils.TimeUtils +import kotlinx.coroutines.delay +import kotlin.coroutines.cancellation.CancellationException @Composable fun RelayGroupTopBar( @@ -154,6 +162,7 @@ fun RelayGroupTopBar( var showJoinCode by remember { mutableStateOf(false) } var confirmDelete by remember { mutableStateOf(false) } val isBuzzRelay = remember(channel.groupId.relayUrl) { BuzzRelayDialect.isBuzz(channel.groupId.relayUrl) } + val huddleLive by observeBuzzHuddleLive(channel, enabled = isBuzzRelay && membership.isMember(), accountViewModel) TopBarExtensibleWithBackButton( title = { @@ -207,6 +216,20 @@ fun RelayGroupTopBar( color = MaterialTheme.colorScheme.onSurfaceVariant, ) } + if (huddleLive) { + Icon( + symbol = MaterialSymbols.Headphones, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(12.dp), + ) + Text( + text = stringRes(Res.string.buzz_huddle_live), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.primary, + maxLines = 1, + ) + } } } }, @@ -556,6 +579,38 @@ private fun RoleBadge(membership: RelayGroupMembership) { } } +/** + * Whether a Buzz huddle is live in [channel] right now. While [enabled] (a Buzz relay we are a + * member of - the relay answers liveness only for an authorized `#h`), re-asks the relay every + * [HUDDLE_LIVENESS_REFRESH_MS] as Buzz's desktop does; a session that stops being reported ages out + * of [BuzzHuddleLivenessState] and the indicator drops. + */ +@Composable +private fun observeBuzzHuddleLive( + channel: RelayGroupChannel, + enabled: Boolean, + accountViewModel: AccountViewModel, +): State { + val seen by BuzzHuddleLivenessState.flow.collectAsStateWithLifecycle() + var now by remember { mutableLongStateOf(TimeUtils.now()) } + LaunchedEffect(channel.groupId, enabled) { + if (!enabled) return@LaunchedEffect + while (true) { + try { + accountViewModel.account.relayGroups.refreshBuzzHuddleLiveness(channel) + } catch (e: Exception) { + if (e is CancellationException) throw e + Log.w("RelayGroupTopBar", "Huddle liveness refresh failed", e) + } + now = TimeUtils.now() + delay(HUDDLE_LIVENESS_REFRESH_MS) + } + } + return remember(channel.groupId, enabled) { derivedStateOf { enabled && BuzzHuddleLivenessState.liveSessions(channel.groupId.id, now, seen).isNotEmpty() } } +} + +private const val HUDDLE_LIVENESS_REFRESH_MS = 10_000L + /** * Whether this Buzz channel has a canvas (kind 40100) in cache, recomposing when one lands. * diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/ChannelNewMessageViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/ChannelNewMessageViewModel.kt index 68ae70fdf0..7acbf11897 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/ChannelNewMessageViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/ChannelNewMessageViewModel.kt @@ -44,6 +44,7 @@ import com.vitorpamplona.amethyst.commons.model.composer.NewMessageTagger import com.vitorpamplona.amethyst.commons.model.composer.SplitBuilder import com.vitorpamplona.amethyst.commons.model.emphChat.EphemeralChatChannel import com.vitorpamplona.amethyst.commons.model.geohashChat.GeohashChatChannel +import com.vitorpamplona.amethyst.commons.model.latestBuzzEdit import com.vitorpamplona.amethyst.commons.model.location.LocationResult import com.vitorpamplona.amethyst.commons.model.nip28PublicChats.PublicChatChannel import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel @@ -76,12 +77,11 @@ import com.vitorpamplona.amethyst.ui.note.creators.location.ILocationGrabber import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.send.IMetaAttachments import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.utils.ChatFileUploadState import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.UserSuggestionAnchor +import com.vitorpamplona.quartz.buzz.stream.BuzzChatMessage +import com.vitorpamplona.quartz.buzz.stream.BuzzEditTagOverlay import com.vitorpamplona.quartz.buzz.stream.StreamMessageEditEvent -import com.vitorpamplona.quartz.buzz.stream.StreamMessageV2Event import com.vitorpamplona.quartz.buzz.stream.mentions -import com.vitorpamplona.quartz.buzz.threading.buzzThread -import com.vitorpamplona.quartz.buzz.threading.buzzThreadReply -import com.vitorpamplona.quartz.buzz.threading.buzzThreadRoot +import com.vitorpamplona.quartz.buzz.threading.buzzThreadRootForReplyTo import com.vitorpamplona.quartz.experimental.bitchat.geohash.GeohashChatEvent import com.vitorpamplona.quartz.experimental.ephemChat.chat.EphemeralChatEvent import com.vitorpamplona.quartz.experimental.nip95.data.FileStorageEvent @@ -114,6 +114,7 @@ import com.vitorpamplona.quartz.nip28PublicChat.base.notify import com.vitorpamplona.quartz.nip28PublicChat.message.ChannelMessageEvent import com.vitorpamplona.quartz.nip29RelayGroups.hTag import com.vitorpamplona.quartz.nip29RelayGroups.moderation.previous +import com.vitorpamplona.quartz.nip30CustomEmoji.EmojiUrlTag import com.vitorpamplona.quartz.nip30CustomEmoji.emojis import com.vitorpamplona.quartz.nip36SensitiveContent.contentWarning import com.vitorpamplona.quartz.nip36SensitiveContent.contentWarningReason @@ -177,11 +178,16 @@ open class ChannelNewMessageViewModel : // thread comment that opens as a minichat. Only meaningful while replyTo is set. val replyMode = mutableStateOf(ReplyMode.INLINE) - // When set, the composer is editing an existing Buzz stream message (kind 40002): + // When set, the composer is editing an existing Buzz message (kind 9, or a legacy 40002): // the next send publishes a kind-40003 edit targeting this note instead of a new // message. Only ever set for own messages on a Buzz-dialect relay (see editBuzzMessage). val editingBuzzMessage = mutableStateOf(null) + // The custom emoji the message being edited currently renders with. Buzz replaces a message's + // emoji set with the edit's whenever the edit carries any, so an edit re-sends the ones still + // used in the text alongside any newly picked ones. + private var editingBuzzEmojis: List = emptyList() + // Explicit @-mentions resolved by the last createTemplate, used by sendPostSync for the Buzz // auto-invite. Kept off the draft path on purpose (see createTemplate / sendPostSync). private var pendingBuzzInviteMentions: List = emptyList() @@ -295,21 +301,30 @@ open class ChannelNewMessageViewModel : } /** - * Enters Buzz edit mode: pre-fills the composer with [note]'s current text and marks - * the next send as a kind-40003 edit of it. Editing and replying are mutually - * exclusive, so any pending reply is cleared. The caller gates this to the user's own - * kind-40002 messages on a Buzz relay. + * Enters Buzz edit mode: pre-fills the composer with [note]'s current text (its newest edit, + * if it has one) and marks the next send as a kind-40003 edit of it. Editing and replying are + * mutually exclusive, so any pending reply is cleared. The caller gates this to the user's + * own kind-9 (or legacy 40002) messages on a Buzz relay. */ fun editBuzzMessage(note: Note) { replyTo.value = null replyMode.value = ReplyMode.INLINE editingBuzzMessage.value = note - message.setTextAndPlaceCursorAtEnd(note.event?.content ?: "") + val latestEdit = note.latestBuzzEdit()?.event + message.setTextAndPlaceCursorAtEnd(latestEdit?.content ?: note.event?.content ?: "") + // Start from what the message shows now: an edit fully replaces its attachments on Buzz, + // so the ones still referenced in the text must go out again with the edit. + val currentTags = BuzzEditTagOverlay.apply(note.event?.tags ?: emptyArray(), latestEdit?.tags) + iMetaAttachments.reset() + iMetaAttachments.addAll(currentTags.imetas()) + editingBuzzEmojis = currentTags.emojis() draftTag.newVersion() } fun clearBuzzEdit() { editingBuzzMessage.value = null + editingBuzzEmojis = emptyList() + iMetaAttachments.reset() message.setTextAndPlaceCursorAtEnd("") draftTag.newVersion() } @@ -592,7 +607,7 @@ open class ChannelNewMessageViewModel : val pk = user.pubkeyHex if (pk != me && channel.membershipOf(pk) == RelayGroupMembership.NONE) { try { - accountViewModel.account.relayGroups.putRelayGroupUser(channel, pk, emptyList()) + accountViewModel.account.relayGroups.addRelayGroupUser(channel, pk) } catch (e: Exception) { if (e is CancellationException) throw e Log.w("BuzzAutoInvite", "Failed to add mentioned member ${pk.take(8)}", e) @@ -640,7 +655,7 @@ open class ChannelNewMessageViewModel : // Buzz relays reject unknown kinds outright, and kind 1111 is not in Buzz's // registry — a minichat comment sent to a workspace channel would be refused // by the relay AFTER the composer already cleared. Buzz replies always go - // through the 40002 branch with its thread markers instead. + // through the Buzz kind-9 branch with its thread markers instead. val minichatAllowed = !(channel is RelayGroupChannel && BuzzRelayDialect.isBuzz(channel.groupId.relayUrl)) val minichatParent = replyTo.value?.takeIf { minichatAllowed && replyMode.value == ReplyMode.MINICHAT }?.event if (minichatParent != null) { @@ -804,55 +819,59 @@ open class ChannelNewMessageViewModel : channel is RelayGroupChannel && BuzzRelayDialect.isBuzz(channel.groupId.relayUrl) && editingBuzzMessage.value != null -> { - // Buzz edit (kind 40003): replaces the text of an existing kind-40002 message. + // Buzz edit (kind 40003): replaces the text of an existing Buzz message. // build() sets the group's `h` tag plus the `e` tag pointing at the edited // message; content is the replacement text. Kept minimal to mirror Buzz's own // `build_edit` (buzz-sdk builders.rs) — the relay validates edits and the author // match. LocalCache overlays the newest edit last-write-wins, so the edited row // re-renders with this content. + // Buzz's clients render an edit with the original's tags overlaid by the edit's + // (BuzzEditTagOverlay): attachments come ONLY from the edit, and custom emoji from + // the edit whenever it has any. So the edit carries every attachment and emoji the + // new text still uses, like Buzz's own `build_message_edit`, or they would vanish. val target = editingBuzzMessage.value!! + val editEmojis = + (emojis + editingBuzzEmojis.filter { tagger.message.contains(":${it.code}:") }) + .distinctBy { it.code } StreamMessageEditEvent.build(channel.groupId.id, target.idHex, tagger.message) { // Carry `p` mentions for anyone cited in the edited text, so a mention added // (or kept) by an edit still notifies the member and resolves its `nostr:` ref. mentions(tagger.pTags?.map { it.pubkeyHex }.orEmpty()) + imetas(usedAttachments) + emojis(editEmojis) } } channel is RelayGroupChannel && BuzzRelayDialect.isBuzz(channel.groupId.relayUrl) -> { - // Buzz workspace message: the native kind is 40002 (stream message v2) - // scoped with the group's `h` tag; Buzz threads replies with NIP-10 - // marked e-tags (["e", root, "", "root"] + ["e", parent, "", "reply"], - // collapsing to a single "reply" when the parent IS the root — mirrors - // thread_tags in buzz-sdk builders.rs) plus a `p` notify to the parent - // author. The dialect check comes from BuzzRelayDialect (marked off - // verified Buzz events), not a channel subtype: channel instances are - // captured by screens for their whole life, so the dialect must be - // able to flip mid-session without swapping objects. + // Buzz workspace message: a kind-9 chat in Buzz's tag shape (`build_message` in + // buzz-sdk builders.rs), which is what Buzz's own desktop, mobile and CLI clients + // write. Kind 40002 is only read now, for older messages. Replies thread with NIP-10 + // marked e-tags (["e", root, "", "root"] + ["e", parent, "", "reply"], collapsing to + // a single "reply" when the parent IS the root) plus a `p` notify to the parent + // author. The dialect check comes from BuzzRelayDialect (marked off verified Buzz + // events), not a channel subtype: channel instances are captured by screens for + // their whole life, so the dialect must be able to flip mid-session without + // swapping objects. // Reply routing (Buzz has no kind-1111): an INLINE reply is flagged `broadcast` so it stays // a flat timeline sibling; a MINICHAT reply omits it so the thread markers pull it into the // message's minichat (mirrors block/buzz's broadcast-vs-thread split). A non-reply is neither. - val broadcastReply = replyTo.value != null && replyMode.value == ReplyMode.INLINE - StreamMessageV2Event.build(channel.groupId.id, tagger.message, broadcast = broadcastReply) { - replyTo.value?.let { parent -> - // The parent's root marker (when it is itself a nested reply), - // else the parent's OWN reply target (a direct reply's collapsed - // form carries the root as its "reply" marker — Buzz's relay - // validates ancestry and rejects a mis-derived root), else the - // parent starts the thread. - val parentTags = parent.event?.tags - val root = - parentTags?.buzzThreadRoot() - ?: parentTags?.buzzThreadReply() - ?: parent.idHex - buzzThread(root, parent.idHex) - } - + val parent = replyTo.value + // The parent's resolved root when it is itself a reply (a direct reply's collapsed form + // carries the root as its "reply" marker), else the parent starts the thread. A parent + // with only a `root` marker counts as top-level: Buzz's relay validates ancestry that + // way and rejects a mis-derived root. + val threadRoot = parent?.let { it.event?.tags?.buzzThreadRootForReplyTo(it.idHex) ?: it.idHex } + BuzzChatMessage.build( + channelId = channel.groupId.id, + content = tagger.message, + threadRoot = threadRoot, + replyTo = parent?.idHex, // `p` mentions for everyone cited in the body (plus the reply target, which the // tagger seeds into pTags) so a named member is notified and the Buzz relay can - // resolve the `nostr:` reference — mirrors the mention p-tags every other chat - // kind above emits. Deduplicated by mentions(), so the reply author isn't doubled. - mentions(tagger.pTags?.map { it.pubkeyHex }.orEmpty()) - + // resolve the `nostr:` reference. Deduplicated, so the reply author isn't doubled. + mentions = tagger.pTags?.map { it.pubkeyHex }.orEmpty(), + broadcast = parent != null && replyMode.value == ReplyMode.INLINE, + ) { hashtags(findHashtags(tagger.message)) references(findURLs(tagger.message)) quotes(findNostrUris(tagger.message)) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/DrawAdditionalInfo.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/DrawAdditionalInfo.kt index bcd53937ec..872e6aabab 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/DrawAdditionalInfo.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/DrawAdditionalInfo.kt @@ -50,6 +50,7 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.buzz.ui.BuzzAgentLabel import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.commons.model.User @@ -152,6 +153,15 @@ fun DrawAdditionalInfo( DrawPlayName(displayName) } + + // A Buzz agent says so, and whose it is: the owner from the NIP-OA tag on its kind 0, + // which opens the owner's profile. + BuzzAgentLabel( + author = baseUser, + owner = user.nipOaOwner, + accountViewModel = accountViewModel, + onOwnerClick = { nav.nav(Route.Profile(it)) }, + ) } if (displayName != user.info.name && !user.info.name.isNullOrBlank()) { diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/model/BuzzWorkspaceChannelTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/model/BuzzWorkspaceChannelTest.kt index 6aa5e58758..aad26aa3b3 100644 --- a/amethyst/src/test/java/com/vitorpamplona/amethyst/model/BuzzWorkspaceChannelTest.kt +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/model/BuzzWorkspaceChannelTest.kt @@ -23,16 +23,22 @@ package com.vitorpamplona.amethyst.model import android.os.Looper import com.vitorpamplona.amethyst.commons.model.buzz.BuzzRelayDialect import com.vitorpamplona.amethyst.commons.model.buzz.BuzzWorkspaceStates +import com.vitorpamplona.amethyst.commons.model.buzzEffectiveAuthor import com.vitorpamplona.amethyst.commons.model.cache.LocalCache +import com.vitorpamplona.amethyst.commons.model.isBuzzEditableBy import com.vitorpamplona.amethyst.commons.model.latestBuzzEdit import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel +import com.vitorpamplona.quartz.buzz.oaOwnerAttestation.OwnerAttestation +import com.vitorpamplona.quartz.buzz.stream.BuzzChatMessage import com.vitorpamplona.quartz.buzz.stream.StreamMessageEditEvent import com.vitorpamplona.quartz.buzz.stream.StreamMessageV2Event import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal import com.vitorpamplona.quartz.nip09Deletions.DeletionRequestEvent import com.vitorpamplona.quartz.nip29RelayGroups.GroupId +import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupMetadataEvent import io.mockk.every import io.mockk.mockk import io.mockk.mockkStatic @@ -214,6 +220,84 @@ class BuzzWorkspaceChannelTest { assertEquals("the fix", target.latestBuzzEdit()?.event?.content) } + @Test + fun anEditOfAKind9MessageOverlaysIt() = + runBlocking { + val channelId = newChannelId() + // Buzz channel messages are kind 9 now; a 40003 targets them the same way. + LocalCache.checkDeletionAndConsume(streamMessage(channelId, "mark the dialect"), buzzRelay, false) + val original = signer.sign(BuzzChatMessage.build(channelId, "teh fix")) + LocalCache.checkDeletionAndConsume(original, buzzRelay, false) + val edit = signer.sign(StreamMessageEditEvent.build(channelId, original.id, "the fix", createdAt = original.createdAt + 1)) + LocalCache.checkDeletionAndConsume(edit, buzzRelay, false) + + assertEquals( + "the fix", + LocalCache + .getNoteIfExists(original.id)!! + .latestBuzzEdit() + ?.event + ?.content, + ) + } + + @Test + fun theVerifiedOwnerOfAnAgentMayEditItsMessages() = + runBlocking { + val channelId = newChannelId() + val agentKeys = KeyPair() + val ownerKeys = KeyPair() + val agent = NostrSignerInternal(agentKeys) + val owner = NostrSignerInternal(ownerKeys) + + val original = agent.sign(StreamMessageV2Event.build(channelId, "agent output")) + LocalCache.checkDeletionAndConsume(original, buzzRelay, false) + val ownerEdit = owner.sign(StreamMessageEditEvent.build(channelId, original.id, "corrected by owner", createdAt = original.createdAt + 10)) + LocalCache.checkDeletionAndConsume(ownerEdit, buzzRelay, false) + val target = LocalCache.getNoteIfExists(original.id)!! + + // Until the agent's profile names the owner, the owner is just another pubkey. + assertNull(target.latestBuzzEdit()) + assertFalse(target.isBuzzEditableBy(owner.pubKey)) + + // The agent's kind 0 carries the owner's NIP-OA attestation for the agent key. + val attestation = OwnerAttestation.sign(agent.pubKey, "", ownerKeys.privKey!!) + val profile = agent.sign(MetadataEvent.newUser("agent", createdAt = original.createdAt) { add(attestation.toTag()) }) + LocalCache.checkDeletionAndConsume(profile, buzzRelay, false) + + assertTrue(target.isBuzzEditableBy(owner.pubKey)) + assertEquals("corrected by owner", target.latestBuzzEdit()?.event?.content) + } + + @Test + fun aRelaySignedMessageIsEditableByTheMemberItNames() = + runBlocking { + val channelId = newChannelId() + val relayKey = NostrSignerInternal(KeyPair()) + val member = NostrSignerInternal(KeyPair()) + val stranger = NostrSignerInternal(KeyPair()) + + // The relay-signed 39000 is what tells us the relay's key. + LocalCache.checkDeletionAndConsume(relayKey.sign(GroupMetadataEvent.build(channelId, name = "ops")), buzzRelay, false) + LocalCache.checkDeletionAndConsume(streamMessage(channelId, "mark the dialect"), buzzRelay, false) + + // A workflow posted on the member's behalf: relay-signed, attributed through `actor`. + val posted = relayKey.sign(BuzzChatMessage.build(channelId, "deploy done") { add(arrayOf("actor", member.pubKey)) }) + LocalCache.checkDeletionAndConsume(posted, buzzRelay, false) + val target = LocalCache.getNoteIfExists(posted.id)!! + assertEquals(member.pubKey, target.buzzEffectiveAuthor()) + + LocalCache.checkDeletionAndConsume(stranger.sign(StreamMessageEditEvent.build(channelId, posted.id, "nope", createdAt = posted.createdAt + 1)), buzzRelay, false) + assertNull(target.latestBuzzEdit()) + LocalCache.checkDeletionAndConsume(member.sign(StreamMessageEditEvent.build(channelId, posted.id, "deploy done (v2)", createdAt = posted.createdAt + 2)), buzzRelay, false) + assertEquals("deploy done (v2)", target.latestBuzzEdit()?.event?.content) + + // A user-signed message can't hand its edits to someone else with an `actor` tag. + val claimed = stranger.sign(BuzzChatMessage.build(channelId, "mine") { add(arrayOf("actor", member.pubKey)) }) + LocalCache.checkDeletionAndConsume(claimed, buzzRelay, false) + assertEquals(stranger.pubKey, LocalCache.getNoteIfExists(claimed.id)!!.buzzEffectiveAuthor()) + } + @Test fun deletingAnEditUnlinksItFromTheMessage() = runBlocking { diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/components/HdrRequestsTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/components/HdrRequestsTest.kt new file mode 100644 index 0000000000..bc594b173d --- /dev/null +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/components/HdrRequestsTest.kt @@ -0,0 +1,69 @@ +/* + * 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.amethyst.ui.components + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class HdrRequestsTest { + private fun requests() = HdrRequests(originalColorMode = 0) + + @Test + fun noRequestsMeansSdr() { + val requests = requests() + assertFalse(requests.wantsHdr) + assertEquals(HdrRequests.UNCAPPED, requests.headroom) + } + + @Test + fun hdrStaysOnUntilTheLastRequestLeaves() { + val requests = requests() + requests.add(2f) + requests.add(2f) + + requests.remove(2f) + assertTrue(requests.wantsHdr) + + requests.remove(2f) + assertFalse(requests.wantsHdr) + } + + @Test + fun theLargestCappedHeadroomWins() { + val requests = requests() + requests.add(2f) + requests.add(3f) + assertEquals(3f, requests.headroom) + } + + @Test + fun anUncappedRequestLiftsTheCap() { + val requests = requests() + requests.add(2f) + requests.add(HdrRequests.UNCAPPED) + assertEquals(HdrRequests.UNCAPPED, requests.headroom) + + requests.remove(HdrRequests.UNCAPPED) + assertEquals(2f, requests.headroom) + } +} diff --git a/cli/README.md b/cli/README.md index c08bea8c4d..7ec8111ffe 100644 --- a/cli/README.md +++ b/cli/README.md @@ -597,7 +597,7 @@ screen speaks. | `amy relaygroup message RELAY GID TEXT` | Post a kind:9 chat message into the group. | | `amy relaygroup edit RELAY GID [--name X] [--about A] [--picture URL] [--banner URL] [--parent GID\|--root] [--private\|--public] [--closed\|--open]` | Edit metadata (9002, admin only). Reads the current 39000 and changes only what you pass: picture, banner, subgroup links, other flags and unknown tags are carried over. | | `amy relaygroup invite RELAY GID --code CODE` | Mint an invite code (9009, moderator). | -| `amy relaygroup put-user RELAY GID PUBKEY [--role admin\|moderator]` | Add or promote a user (9000, moderator). | +| `amy relaygroup put-user RELAY GID PUBKEY [--role admin\|moderator] [--buzz-role owner\|admin\|member\|guest\|bot]` | Add or promote a user (9000, moderator). On Buzz, only `--buzz-role` sets a role; without it an existing member keeps theirs. | | `amy relaygroup remove-user RELAY GID PUBKEY` | Kick a user (9001, moderator). | | `amy relaygroup pin RELAY GID REF` / `unpin …` | Add/remove a pin (9010, moderator). REF is a note1/nevent1/hex id (`e`) or naddr1/`kind:pubkey:d` (`a`); the rest of the current 39005 list (signed by the relay's NIP-11 `self`) is kept; if that list cannot be read the command aborts (`timeout` → 124, `fetch_failed`/`no_relay_key` → 1) rather than overwrite it. | @@ -605,13 +605,13 @@ screen speaks. [`block/buzz`](https://github.com/block/buzz) workspaces are NIP-29 groups on a Buzz relay, so create/join/leave still use `amy relaygroup`. These verbs cover the Buzz-native -surface: the kind:40002 stream message, the owner-attestation primitive (NIP-OA), and the +surface: the Buzz channel message (kind:9), the owner-attestation primitive (NIP-OA), and the agent console (turn-metric aggregation + personas), all driving the same `quartz` models and `commons` aggregator the app uses. | Command | What it does | | --- | --- | -| `amy buzz post RELAY GID ` | Post a kind:40002 stream message (Buzz-native) into a workspace. | +| `amy buzz post RELAY GID ` | Post a channel message into a workspace: kind:9 in Buzz's tag shape, what Buzz's own clients write. | | `amy buzz read RELAY GID [--limit N] [--timeout SECS]` | Read the recent human-visible timeline (kinds 9 / 40002 / 40099). | | `amy buzz attest AGENT [--kind K] [--after UNIX] [--before UNIX]` | Sign a NIP-OA attestation authorizing AGENT (offline; needs a local key). Prints the `auth` tag to hand to the agent operator. | | `amy buzz console [--relays R,R] [--timeout SECS]` | Fetch my kind:44200 turn metrics (`#p`=me), decrypt, and aggregate fleet + per-agent cost/tokens. | diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt index 87caabad5f..ac5ab57c5c 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt @@ -830,7 +830,7 @@ private fun printUsage() { | relaygroup remove-user RELAY GID PUBKEY kick a user (kind 9001) | |Buzz (block/buzz agent workspaces — NIP-29 dialect): - | buzz post RELAY GID post a kind-40002 stream message + | buzz post RELAY GID post a channel message (kind 9) | buzz read RELAY GID [--limit N] read recent workspace messages | buzz attest AGENT [--kind K] issue a NIP-OA attestation (offline) | buzz console [--relays R,R] aggregate my kind-44200 agent turn metrics diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/BuzzCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/BuzzCommands.kt index f296b76fba..99afd73d7e 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/BuzzCommands.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/BuzzCommands.kt @@ -30,10 +30,12 @@ import com.vitorpamplona.quartz.buzz.apPersonas.PersonaEvent import com.vitorpamplona.quartz.buzz.dm.DmAddMemberEvent import com.vitorpamplona.quartz.buzz.dm.DmHideEvent import com.vitorpamplona.quartz.buzz.dm.DmOpenEvent +import com.vitorpamplona.quartz.buzz.invite.BuzzInviteClaim import com.vitorpamplona.quartz.buzz.invite.BuzzInviteLink import com.vitorpamplona.quartz.buzz.notifications.MemberAddedNotificationEvent import com.vitorpamplona.quartz.buzz.oaOwnerAttestation.AttestationConditions import com.vitorpamplona.quartz.buzz.oaOwnerAttestation.OwnerAttestation +import com.vitorpamplona.quartz.buzz.stream.BuzzChatMessage import com.vitorpamplona.quartz.buzz.stream.StreamMessageV2Event import com.vitorpamplona.quartz.buzz.stream.SystemMessageEvent import com.vitorpamplona.quartz.nip01Core.core.HexKey @@ -63,7 +65,7 @@ import okhttp3.coroutines.executeAsync * `amy buzz …` — first-class access to the `block/buzz` workspace protocol, driving the * same `quartz` models + `commons` aggregator the app uses. Buzz workspaces are NIP-29 * groups, so join/leave/create still go through `amy relaygroup`; this verb group covers - * the Buzz-native pieces: stream messages (40002), the owner-attestation primitive (OA), + * the Buzz-native pieces: channel messages (kind 9 in Buzz's shape), the owner-attestation primitive (OA), * and the agent console (turn-metric aggregation + personas). */ object BuzzCommands { @@ -345,12 +347,24 @@ object BuzzCommands { }.toString() val authEvent = ctx.signer.sign(HTTPAuthorizationEvent.build(claimUrl, "POST", claimReq.encodeToByteArray())) val (claimCode, claimBody) = httpPost(http, claimUrl, claimReq, authEvent.toAuthToken()) - if (claimCode != 200) return Output.error("claim_failed", "invite claim failed ($claimCode): $claimBody") + if (claimCode != 200) { + return when (BuzzInviteClaim.errorOf(claimBody)) { + BuzzInviteClaim.ERROR_EXHAUSTED -> Output.error("exhausted", "this invite has no uses left; ask for a new one") + BuzzInviteClaim.ERROR_EXPIRED -> Output.error("expired", "this invite has expired") + BuzzInviteClaim.ERROR_INVALID -> Output.error("invalid", "this invite is not valid for this workspace (revoked, mistyped, or never minted here)") + BuzzInviteClaim.ERROR_JOIN_POLICY_REQUIRED -> + Output.error("policy_required", "this workspace requires accepting its terms + age attestation; re-run with --accept-policy to consent") + else -> Output.error("claim_failed", "invite claim failed ($claimCode): $claimBody") + } + } val result = jsonParser.parseToJsonElement(claimBody).jsonObject + val status = result["status"]?.jsonPrimitive?.content Output.emit( mapOf( - "status" to result["status"]?.jsonPrimitive?.content, + "status" to status, + // Distinguish a no-op claim (already a member; no invite use consumed) from a join. + "already_member" to (status == BuzzInviteClaim.STATUS_ALREADY_MEMBER), "community_id" to (result["community_id"]?.jsonPrimitive?.content ?: invite.communityId), "role" to (result["role"]?.jsonPrimitive?.content ?: invite.role), "host" to invite.host, @@ -389,7 +403,7 @@ object BuzzCommands { http.newCall(builder.build()).executeAsync().use { it.code to it.body.string() } } - /** `buzz post RELAY GID ` → publishes a kind-40002 stream message with an `h` tag. */ + /** `buzz post RELAY GID ` → publishes a kind-9 channel message in Buzz's shape (`build_message`). */ private suspend fun post( dataDir: DataDir, rest: Array, @@ -398,7 +412,7 @@ object BuzzCommands { val text = Args(rest).positionalOrNull(2) ?: return Output.error("bad_args", usage) if (text.isBlank()) return Output.error("bad_args", "message text must not be blank") return publishScoped(dataDir, rest, usage) { _, groupId, _ -> - StreamMessageV2Event.build(groupId, text) + BuzzChatMessage.build(groupId, text) } } @@ -524,6 +538,9 @@ object BuzzCommands { "total_tokens" to metrics.totals.totalTokens, "input_tokens" to metrics.totals.inputTokens, "output_tokens" to metrics.totals.outputTokens, + // null = never reported (NIP-AM: an omitted cache component is unknown, not zero) + "cache_read_tokens" to metrics.totals.cacheReadTokens, + "cache_write_tokens" to metrics.totals.cacheWriteTokens, "turns" to metrics.totalTurns, "sessions" to metrics.totalSessions, "agents" to metrics.agents.size, diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/RelayGroupModerationCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/RelayGroupModerationCommands.kt index 569120d012..1397150e40 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/RelayGroupModerationCommands.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/RelayGroupModerationCommands.kt @@ -24,6 +24,7 @@ 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.quartz.buzz.workspace.BUZZ_ROLES import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.FetchAllResult import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter @@ -258,13 +259,19 @@ object RelayGroupModerationCommands { } } - /** `relaygroup put-user RELAY GROUP_ID PUBKEY [--role admin|moderator]` → 9000. */ + /** + * `relaygroup put-user RELAY GROUP_ID PUBKEY [--role admin|moderator] [--buzz-role ROLE]` → 9000. + * + * Buzz ignores the roles in the `p` tag and reads only a top-level `role` tag + * (owner|admin|member|guest|bot); `--buzz-role` sets it. Without it a Buzz relay makes no + * role change: an existing member keeps their role and a newcomer joins as `member`. + */ suspend fun putUser( dataDir: DataDir, rest: Array, ): Int { val args = Args(rest) - val usage = "relaygroup put-user RELAY GROUP_ID PUBKEY [--role admin|moderator]" + val usage = "relaygroup put-user RELAY GROUP_ID PUBKEY [--role admin|moderator] [--buzz-role owner|admin|member|guest|bot]" val relayUrl = args.positionalOrNull(0) ?: return Output.error("bad_args", usage) val groupId = args.positionalOrNull(1) ?: return Output.error("bad_args", usage) val user = args.positionalOrNull(2) ?: return Output.error("bad_args", usage) @@ -275,12 +282,14 @@ object RelayGroupModerationCommands { ?.split(',') ?.map { it.trim() } ?.filter { it.isNotEmpty() } ?: emptyList() + val buzzRole = args.flag("buzz-role")?.trim()?.lowercase() + if (buzzRole != null && buzzRole !in BUZZ_ROLES) return Output.error("bad_args", usage) args.rejectUnknown() Context.open(dataDir).use { ctx -> ctx.prepare() val pubkey = ctx.requireUserHex(user) - val signed = ctx.signer.sign(GroupPutUserEvent.build(groupId, listOf(pubkey to roles))) + val signed = ctx.signer.sign(GroupPutUserEvent.build(groupId, listOf(pubkey to roles), buzzRole = buzzRole)) val ack = ctx.publish(signed, setOf(relay)) RawEventSupport.publishGuard(ack, signed.id)?.let { return it } Output.emit( @@ -290,6 +299,7 @@ object RelayGroupModerationCommands { "relay" to relay.url, "pubkey" to pubkey, "roles" to roles, + "buzz_role" to buzzRole, "published" to ack.values.any { it.accepted }, ), ) diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/Account.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/Account.kt index 3eaf61faa1..dca2865779 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/Account.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/Account.kt @@ -186,8 +186,7 @@ import com.vitorpamplona.amethyst.commons.service.upload.FileHeader import com.vitorpamplona.amethyst.commons.util.logTime import com.vitorpamplona.amethyst.commons.viewmodels.ReplyMode import com.vitorpamplona.quartz.buzz.threading.buzzThread -import com.vitorpamplona.quartz.buzz.threading.buzzThreadReply -import com.vitorpamplona.quartz.buzz.threading.buzzThreadRoot +import com.vitorpamplona.quartz.buzz.threading.buzzThreadRootForReplyTo import com.vitorpamplona.quartz.concord.cord03Channels.ChannelChat import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChannelId import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChatEditEvent @@ -2239,7 +2238,7 @@ class Account( // [com.vitorpamplona.amethyst.commons.model.chats.isMinichatReply]). // // Attached media rides as URLs appended to the content. - val root = rootEvent.tags.buzzThreadRoot() ?: rootEvent.tags.buzzThreadReply() ?: rootEvent.id + val root = rootEvent.tags.buzzThreadRootForReplyTo(rootEvent.id) signer.sign( ChatEvent.build(finalText) { hTag(group.groupId.id) diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/AccountRelayGroupActions.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/AccountRelayGroupActions.kt index 120dc22ff8..b9f51ea649 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/AccountRelayGroupActions.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/AccountRelayGroupActions.kt @@ -21,6 +21,7 @@ package com.vitorpamplona.amethyst.commons.model import com.vitorpamplona.amethyst.commons.model.Note +import com.vitorpamplona.amethyst.commons.model.buzz.BuzzHuddleLivenessState import com.vitorpamplona.amethyst.commons.model.buzz.BuzzRelayDialect import com.vitorpamplona.amethyst.commons.model.buzz.WorkflowRunPayload import com.vitorpamplona.amethyst.commons.model.cache.LocalCache @@ -30,6 +31,7 @@ import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupMembe import com.vitorpamplona.quartz.buzz.dm.DmAddMemberEvent import com.vitorpamplona.quartz.buzz.dm.DmHideEvent import com.vitorpamplona.quartz.buzz.dm.DmOpenEvent +import com.vitorpamplona.quartz.buzz.huddles.HuddleLivenessEvent import com.vitorpamplona.quartz.buzz.jobs.JobCancelEvent import com.vitorpamplona.quartz.buzz.jobs.JobRequestEvent import com.vitorpamplona.quartz.buzz.presence.TypingIndicatorEvent @@ -41,13 +43,17 @@ import com.vitorpamplona.quartz.buzz.workflow.WorkflowDefEvent import com.vitorpamplona.quartz.buzz.workflow.WorkflowTriggerEvent import com.vitorpamplona.quartz.buzz.workflow.workflowChannel import com.vitorpamplona.quartz.buzz.workspace.BUZZ_ROLE_ADMIN +import com.vitorpamplona.quartz.buzz.workspace.BUZZ_ROLE_BOT +import com.vitorpamplona.quartz.buzz.workspace.BUZZ_ROLE_GUEST import com.vitorpamplona.quartz.buzz.workspace.BUZZ_ROLE_MEMBER +import com.vitorpamplona.quartz.buzz.workspace.BUZZ_ROLE_OWNER import com.vitorpamplona.quartz.buzz.workspace.BUZZ_VISIBILITY_OPEN import com.vitorpamplona.quartz.buzz.workspace.BUZZ_VISIBILITY_PRIVATE import com.vitorpamplona.quartz.nip01Core.core.Address import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.PublishResult +import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.fetchAll import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.fetchAllWithHooks import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.publishAndCollectResults import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter @@ -75,9 +81,11 @@ import com.vitorpamplona.quartz.nip29RelayGroups.tags.EventPin import com.vitorpamplona.quartz.nip29RelayGroups.tags.GroupIdTag import com.vitorpamplona.quartz.nip29RelayGroups.tags.GroupPin import com.vitorpamplona.quartz.nip7DThreads.ThreadEvent -import com.vitorpamplona.quartz.utils.RandomInstance +import com.vitorpamplona.quartz.utils.TimeUtils import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json +import kotlin.uuid.ExperimentalUuidApi +import kotlin.uuid.Uuid /** * NIP-29 relay-group and Buzz-workspace orchestration for an [Account]: @@ -158,6 +166,21 @@ class AccountRelayGroupActions( ?.substringBefore('"') ?.takeIf { it.isNotBlank() } + /** + * Asks [channel]'s Buzz relay which huddle sessions in it are live right now and records them + * in [BuzzHuddleLivenessState]. The relay synthesizes kind-48104 answers only for a REQ whose + * every filter is exactly `kinds:[48104]` with an authorized `#h`, so this is its own REQ, never + * part of the channel's timeline subscription. Buzz's desktop repeats it every 10 seconds. + */ + suspend fun refreshBuzzHuddleLiveness(channel: RelayGroupChannel) { + val filter = HuddleLivenessEvent.filter(listOf(channel.groupId.id)) + val now = TimeUtils.now() + account.client.fetchAll(channel.groupId.relayUrl, filter, idleTimeoutMs = 8_000).forEach { event -> + if (event !is HuddleLivenessEvent || event.channelId() != channel.groupId.id) return@forEach + event.sessionId()?.let { BuzzHuddleLivenessState.record(channel.groupId.id, it, now) } + } + } + /** Hide a Buzz DM from my sidebar with a kind-41012 command (re-opening it un-hides). */ suspend fun hideBuzzDm(channel: RelayGroupChannel) { val template = DmHideEvent.build(channel.groupId.id) @@ -231,6 +254,7 @@ class AccountRelayGroupActions( * the YAML and runs it; self-hosted on geode the definition is a named catalog entry the picker * offers and `amy` triggers by id. Returns the new workflow id, or null when the account can't write. */ + @OptIn(ExperimentalUuidApi::class) suspend fun publishBuzzWorkflowDef( relay: NormalizedRelayUrl, channelId: String, @@ -238,7 +262,8 @@ class AccountRelayGroupActions( yaml: String, ): String? { if (!account.isWriteable()) return null - val workflowId = RandomInstance.randomChars(16) + // The relay parses the `d` tag as a UUID and refuses anything else. + val workflowId = Uuid.random().toString() val signed = account.signer.sign(WorkflowDefEvent.build(workflowId, channelId, yaml, name.ifBlank { null })) account.cache.justConsumeMyOwnEvent(signed) account.client.publish(signed, setOf(relay)) @@ -493,8 +518,28 @@ class AccountRelayGroupActions( } /** - * Add [pubkey] to the group (or change its roles) with a kind 9000 put-user - * event (moderator only). Pass an empty [roles] list for a plain member. + * Add [pubkey] to the group as a plain member with a kind 9000 put-user event, without + * touching the role of someone who is already in it. + * + * On Buzz this sends **no** `role` tag: the relay reads a missing role as "no role change", + * keeping an existing member's role and defaulting only a newcomer to `member`. Sending + * `role=member` instead would demote an admin who happened to be re-added, or be refused + * outright (`only owners/admins may change an active member's role`) when the actor is not + * elevated. Ground truth: `handle_put_user` in `buzz-relay/src/handlers/side_effects.rs` and + * `decide_put_user` in `channel_authz.rs`. + */ + suspend fun addRelayGroupUser( + channel: RelayGroupChannel, + pubkey: HexKey, + ) { + val template = GroupPutUserEvent.build(channel.groupId.id, listOf(pubkey to emptyList())) + account.broadcaster.signAndSendPrivatelyOrBroadcast(template) { channel.relays().toList() } + } + + /** + * Set [pubkey]'s roles in the group with a kind 9000 put-user event (moderator only). An + * empty [roles] list makes them a plain member - on Buzz that is an explicit demotion to + * `member`. To add someone without changing a role, use [addRelayGroupUser]. */ suspend fun putRelayGroupUser( channel: RelayGroupChannel, @@ -506,10 +551,7 @@ class AccountRelayGroupActions( // the whole put-user, which is why an unmapped role must become `member` rather than travel. val buzzRole = if (BuzzRelayDialect.isBuzz(channel.groupId.relayUrl)) { - when { - roles.any { it.equals(RelayGroupMembership.ROLE_ADMIN, true) } -> BUZZ_ROLE_ADMIN - else -> BUZZ_ROLE_MEMBER - } + buzzRoleFor(roles) } else { null } @@ -606,3 +648,18 @@ class AccountRelayGroupActions( account.broadcaster.signAndSendPrivatelyOrBroadcast(template) { channel.relays().toList() } } } + +/** + * Maps NIP-29 role names onto Buzz's closed role set (owner/admin/member/guest/bot), picking the + * most privileged one present. Anything else, including NIP-29's `moderator`, becomes `member`. + */ +internal fun buzzRoleFor(roles: List): String { + fun has(role: String) = roles.any { it.equals(role, true) } + return when { + has(BUZZ_ROLE_OWNER) -> BUZZ_ROLE_OWNER + has(RelayGroupMembership.ROLE_ADMIN) || has(BUZZ_ROLE_ADMIN) -> BUZZ_ROLE_ADMIN + has(BUZZ_ROLE_BOT) -> BUZZ_ROLE_BOT + has(BUZZ_ROLE_GUEST) -> BUZZ_ROLE_GUEST + else -> BUZZ_ROLE_MEMBER + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/NoteEditOverlays.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/NoteEditOverlays.kt index b3ebcb6b5e..644f9e5f9f 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/NoteEditOverlays.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/NoteEditOverlays.kt @@ -20,11 +20,15 @@ */ package com.vitorpamplona.amethyst.commons.model +import com.vitorpamplona.amethyst.commons.model.cache.LocalCache +import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel import com.vitorpamplona.quartz.buzz.stream.StreamMessageEditEvent import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChatEditEvent import com.vitorpamplona.quartz.experimental.edits.TextNoteModificationEvent import com.vitorpamplona.quartz.marmot.foundation.appEvents.MarmotAppEvent +import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip40Expiration.isExpirationBefore +import com.vitorpamplona.quartz.utils.Hex import com.vitorpamplona.quartz.utils.TimeUtils /* @@ -33,9 +37,11 @@ import com.vitorpamplona.quartz.utils.TimeUtils * in-memory folds — no cache scan, no LocalCache state involved, which is why they live on the * note rather than the cache. * - * All four kinds apply ONLY edits authored by the edited note's own author: the send side gates - * editing to your own messages, and neither the relay (Buzz) nor an encrypted-plane peer (Concord, - * Marmot) is trusted to enforce that, so a foreign-authored edit never rewrites your message. + * Every kind applies ONLY edits the message's author was entitled to make, checked here rather + * than trusted to the relay (Buzz) or an encrypted-plane peer (Concord, Marmot), so a + * foreign-authored edit never rewrites your message. For Concord, Marmot and kind-1010 that is the + * note's own author. Buzz also lets the owner of an agent edit the agent's messages, and credits a + * relay-signed message to the member it names ([buzzEffectiveAuthor], [buzzEditorsOf]). */ /** @@ -52,11 +58,65 @@ fun Note.textNoteModifications(): List { }.sortedWith(compareBy({ it.createdAt() }, { it.idHex })) } -/** The kind-40003 Buzz edit overlaying this message, or null — author-only, newest by created_at. */ -fun Note.latestBuzzEdit(): Note? { - val authorHex = author?.pubkeyHex ?: return null - return edits - .filter { it.event is StreamMessageEditEvent && it.author?.pubkeyHex == authorHex } +/** + * Who a Buzz message counts as written by. Normally its signer; but when the channel's relay signed + * it on a member's behalf (a workflow posting, a legacy relay-attributed message) the member named in + * its `actor` tag, else its first `p` tag. The relay's key is the author of the channel's + * relay-signed 39000 metadata, so a user-signed message can never claim someone else this way. + * Mirrors `effective_message_author` in Buzz's `buzz-relay/src/handlers/ingest.rs` and + * `resolveEventAuthorPubkey` in its desktop client (which also wants the `h` tag before reading `p`). + */ +fun Note.buzzEffectiveAuthor(): HexKey? { + val event = event ?: return author?.pubkeyHex + val relayKey = inGatherers?.firstNotNullOfOrNull { (it as? RelayGroupChannel)?.event?.pubKey } + if (relayKey != null && event.pubKey == relayKey) { + event.tags.firstOrNull { it.size > 1 && it[0] == "actor" && Hex.isHex64(it[1]) }?.let { return it[1] } + if (event.tags.any { it.size > 1 && it[0] == "h" }) { + event.tags.firstOrNull { it.size > 1 && it[0] == "p" && Hex.isHex64(it[1]) }?.let { return it[1] } + } + } + return event.pubKey +} + +/** + * Whether [signer] may edit this Buzz message: its [buzzEffectiveAuthor], or the owner that author + * declares through a verified NIP-OA `auth` tag on its kind-0 profile (Buzz lets the owning human + * edit its agent's messages). Mirrors `validate_edit_ownership` on the relay and + * `isAuthorizedMessageEdit` in Buzz's desktop client. + */ +fun Note.isBuzzEditableBy( + signer: HexKey, + users: (HexKey) -> User? = LocalCache::getUserIfExists, +): Boolean { + val author = buzzEffectiveAuthor() ?: return false + return signer == author || signer == buzzOwnerOf(author, users) +} + +private fun buzzOwnerOf( + author: HexKey, + users: (HexKey) -> User?, +): HexKey? = + users(author) + ?.metadataOrNull() + ?.flow + ?.value + ?.nipOaOwner + +/** + * The kind-40003 Buzz edit overlaying this message, or null: the newest ([createdAt], then id) of + * the edits its author or the author's agent owner made ([isBuzzEditableBy]). + */ +fun Note.latestBuzzEdit(users: (HexKey) -> User? = LocalCache::getUserIfExists): Note? { + val candidates = edits.filter { it.event is StreamMessageEditEvent } + if (candidates.isEmpty()) return null + val author = buzzEffectiveAuthor() ?: return null + // Resolve the owner only when some edit is not the author's own; it costs a signature check. + val owner by lazy { buzzOwnerOf(author, users) } + return candidates + .filter { + val signer = it.author?.pubkeyHex + signer != null && (signer == author || signer == owner) + } // idHex tie-break so a same-second pair resolves identically on every client. .maxWithOrNull(compareBy({ it.createdAt() ?: 0L }, { it.idHex })) } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/buzz/AgentFleetMetrics.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/buzz/AgentFleetMetrics.kt index fb5c1e8b79..e5f28674dd 100644 Binary files a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/buzz/AgentFleetMetrics.kt and b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/buzz/AgentFleetMetrics.kt differ diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/buzz/BuzzCanvasWriter.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/buzz/BuzzCanvasWriter.kt new file mode 100644 index 0000000000..4d03ea2531 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/buzz/BuzzCanvasWriter.kt @@ -0,0 +1,105 @@ +/* + * 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.amethyst.commons.model.buzz + +import com.vitorpamplona.amethyst.commons.model.Account +import com.vitorpamplona.quartz.buzz.stream.CanvasEvent +import com.vitorpamplona.quartz.buzz.stream.tags.ExpectedRevisionTag +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.PublishResult +import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.publishAndCollectResults +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.utils.TimeUtils + +/** + * Publishes a Buzz canvas (kind 40100) edit with the writer discipline Buzz's own clients use + * (`set_canvas` in `desktop/src-tauri/src/commands/canvas.rs`): + * + * - the write asserts the head the editor was opened on with `["expected-revision", ]`, + * or `none` when there was no canvas yet, so the relay refuses it (`conflict: …`) instead of + * silently overwriting someone else's newer revision; + * - it is stamped `created_at = max(now, head.created_at + 1)` ([CanvasEvent.writeCreatedAt]) so + * it sorts strictly ahead of that head, and is refused outright when the head sits too far in + * the future to ratchet past. + * + * Unlike the fire-and-forget broadcaster, this waits for the relay's OK: a rejected write must + * not be folded into [BuzzWorkspaceStates] as the new head, and the conflict has to reach the UI. + */ +object BuzzCanvasWriter { + sealed interface Outcome { + /** The relay stored the revision; it is now the local head too. */ + data class Saved( + val event: CanvasEvent, + ) : Outcome + + /** The canvas changed since the editor loaded it (the relay's `conflict:` rejection). */ + data class Conflict( + val message: String, + ) : Outcome + + /** The loaded head is timestamped too far in the future to write after; nothing was sent. */ + data object HeadTooFarInFuture : Outcome + + /** Any other rejection, or no answer at all. */ + data class Failed( + val message: String, + ) : Outcome + } + + /** + * Signs and publishes [markdown] as the new canvas of [channelId] on [relay], asserting the + * head the editor started from ([headId] / [headCreatedAt], both null when there was none). + */ + suspend fun save( + account: Account, + relay: NormalizedRelayUrl, + channelId: String, + markdown: String, + headId: HexKey?, + headCreatedAt: Long?, + now: Long = TimeUtils.now(), + ): Outcome { + val createdAt = CanvasEvent.writeCreatedAt(headCreatedAt, now) ?: return Outcome.HeadTooFarInFuture + val template = CanvasEvent.build(channelId, markdown, createdAt, expectedRevision = headId ?: ExpectedRevisionTag.NONE) + val signed = account.signer.sign(template) + + var results = account.client.publishAndCollectResults(signed, setOf(relay)) + // A cold socket answers the first write with `auth-required` while our AUTH lands; the + // relay does not replay it, so send once more on the now-authenticated connection. + if (results.values.none { it.accepted } && results.values.any { it.message.contains("auth-required", ignoreCase = true) }) { + results = account.client.publishAndCollectResults(signed, setOf(relay)) + } + + val outcome = classify(signed, results.values) + if (outcome is Outcome.Saved) account.cache.justConsumeMyOwnEvent(signed) + return outcome + } + + /** Maps the relay's OK answers to an [Outcome]: any acceptance wins, then a `conflict:`, then the first reason. */ + fun classify( + event: CanvasEvent, + results: Collection, + ): Outcome { + if (results.any { it.accepted }) return Outcome.Saved(event) + results.firstOrNull { CanvasEvent.isConflict(it.message) }?.let { return Outcome.Conflict(it.message) } + return Outcome.Failed(results.firstOrNull()?.message ?: PublishResult.NO_RESPONSE) + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/buzz/BuzzHeldAttestations.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/buzz/BuzzHeldAttestations.kt index 5ec6667c26..55f585e1ea 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/buzz/BuzzHeldAttestations.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/buzz/BuzzHeldAttestations.kt @@ -22,6 +22,7 @@ package com.vitorpamplona.amethyst.commons.model.buzz import com.vitorpamplona.quartz.buzz.oaOwnerAttestation.OwnerAttestation import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.utils.TimeUtils import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -50,10 +51,12 @@ class BuzzHeldAttestations( val flow: StateFlow = held /** - * The `auth` tag to attach to this account's NIP-42 AUTH event, or null when no verified - * attestation is held. + * The `auth` tag to attach to an AUTH event signed at [authCreatedAt], or null when no verified + * attestation is held or its `created_at<` / `created_at>` bounds exclude that moment. Buzz + * checks those bounds against the AUTH event's timestamp and refuses membership outside them, + * so an expired credential stays held (the UI can say it expired) but is not presented. */ - fun authTag(): Array? = held.value?.toTag() + fun authTag(authCreatedAt: Long = TimeUtils.now()): Array? = held.value?.takeIf { it.isValidAt(authCreatedAt) }?.toTag() /** * Stores [attestation] as authorizing this account, if it verifies for [agentPubKey]. Returns diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/buzz/BuzzHuddleLivenessState.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/buzz/BuzzHuddleLivenessState.kt new file mode 100644 index 0000000000..f3e687ad1e --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/buzz/BuzzHuddleLivenessState.kt @@ -0,0 +1,78 @@ +/* + * 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.amethyst.commons.model.buzz + +import com.vitorpamplona.amethyst.commons.util.KmpLock +import com.vitorpamplona.amethyst.commons.util.withLock +import kotlinx.collections.immutable.PersistentMap +import kotlinx.collections.immutable.persistentMapOf +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow + +/** + * Which Buzz huddle sessions are live right now, per channel, fed by the relay-synthesized + * kind-48104 liveness events (`HuddleLivenessEvent`). The relay builds those on demand from its + * live huddle table, answers only for sessions that are live, and never stores them, so a + * session's liveness is "seen in a recent answer": Buzz's desktop re-asks every 10 seconds and + * treats a session that stops appearing as ended. [LIVE_WINDOW_SECS] leaves room for two missed + * polls before a huddle drops off. + * + * Shape: `channelId -> sessionId -> last seen (seconds)`. Lock-guarded because `LocalCache` + * consume runs on several relay reader threads. Process-wide singleton like [BuzzPresenceState]. + */ +object BuzzHuddleLivenessState { + const val LIVE_WINDOW_SECS = 30L + + private val lock = KmpLock() + private val mutableSeen = MutableStateFlow>>(persistentMapOf()) + + /** `channelId -> sessionId -> last seen`; collect it and filter with [liveSessions]. */ + val flow: StateFlow>> = mutableSeen + + /** Records that the relay reported [sessionId] in [channelId] live as of [seenAtSecs]. */ + fun record( + channelId: String, + sessionId: String, + seenAtSecs: Long, + ) = lock.withLock { + val sessions = mutableSeen.value[channelId] ?: persistentMapOf() + val prev = sessions[sessionId] + if (prev != null && seenAtSecs <= prev) return@withLock + mutableSeen.value = mutableSeen.value.putting(channelId, sessions.putting(sessionId, seenAtSecs)) + } + + /** The sessions in [channelId] reported live within [LIVE_WINDOW_SECS] of [nowSecs]. */ + fun liveSessions( + channelId: String, + nowSecs: Long, + snapshot: Map> = mutableSeen.value, + ): Set = + snapshot[channelId] + ?.filterValues { nowSecs - it <= LIVE_WINDOW_SECS } + ?.keys + .orEmpty() + + /** Test-only: clears all state so unit tests don't leak into each other. */ + fun clearForTesting() = + lock.withLock { + mutableSeen.value = persistentMapOf() + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/buzz/BuzzIdentityNames.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/buzz/BuzzIdentityNames.kt new file mode 100644 index 0000000000..7ba41bdd9a --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/buzz/BuzzIdentityNames.kt @@ -0,0 +1,148 @@ +/* + * 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.amethyst.commons.model.buzz + +import com.vitorpamplona.amethyst.commons.model.User +import com.vitorpamplona.amethyst.commons.model.cache.LocalCache +import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel +import com.vitorpamplona.amethyst.commons.util.KmpLock +import com.vitorpamplona.amethyst.commons.util.withLock +import com.vitorpamplona.quartz.buzz.agentProfiles.AgentProfileEvent +import com.vitorpamplona.quartz.buzz.identityNames.IdentityNamePolicy +import com.vitorpamplona.quartz.buzz.identityNames.NamingIdentity +import com.vitorpamplona.quartz.buzz.identityNames.ResolvedIdentityName +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip29RelayGroups.GroupId +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow + +/** + * Buzz's contextual identity names for one relay group: each member's label, lengthened only when + * someone else in the channel shares the name ("Alice’s Honey", "Honey (agent)", "Honey · 7xk2"). See + * [IdentityNamePolicy] for the rule. Names are compared against the channel's members, so a label only + * grows when a real collision exists there; a non-member author is resolved against the members plus + * itself. + * + * Each channel's result is cached until its roster changes or any profile or agent profile arrives + * ([invalidate], which bumps [version]); the UI can collect [version] to redraw. + */ +object BuzzIdentityNames { + private val mutableVersion = MutableStateFlow(0L) + + /** Bumped whenever a name, agent profile or owner may have changed. */ + val version: StateFlow = mutableVersion + + /** A kind-0 or kind-10100 landed; names computed so far may be stale. */ + fun invalidate() { + mutableVersion.value = mutableVersion.value + 1 + } + + private class Entry( + val members: Set, + val version: Long, + val viewer: HexKey, + val resolved: Map, + val outside: MutableMap = HashMap(), + ) + + private val lock = KmpLock() + private val cache = HashMap() + + /** + * The contextual label for [pubkey] in [channel] as [viewer] sees it, or null when there is no + * name to disambiguate (an invalid key). The label equals the plain name unless it collides. + */ + fun labelFor( + channel: RelayGroupChannel, + pubkey: HexKey, + viewer: HexKey, + users: (HexKey) -> User? = LocalCache::getUserIfExists, + hasAgentProfile: (HexKey) -> Boolean = ::hasCachedAgentProfile, + ): ResolvedIdentityName? { + val members = channel.allMemberKeys() + val version = mutableVersion.value + val entry = + lock.withLock { + cache[channel.groupId]?.takeIf { it.members === members && it.version == version && it.viewer == viewer } + } ?: Entry(members, version, viewer, resolve(members, viewer, users, hasAgentProfile)).also { lock.withLock { cache[channel.groupId] = it } } + + val key = pubkey.lowercase() + if (key in entry.resolved) return entry.resolved[key] + return lock.withLock { entry.outside[key] } + ?: resolve(members + key, viewer, users, hasAgentProfile)[key].also { found -> lock.withLock { entry.outside[key] = found } } + } + + /** The naming fact for [pubkey]: its profile name, and whether (and whose) agent it is. */ + fun factFor( + pubkey: HexKey, + users: (HexKey) -> User?, + hasAgentProfile: (HexKey) -> Boolean = ::hasCachedAgentProfile, + ): NamingIdentity { + val user = users(pubkey) + val info = user?.metadataOrNull()?.flow?.value + val owner = info?.nipOaOwner + return NamingIdentity( + pubkey = pubkey, + name = info?.info?.bestName()?.takeIf { IdentityNamePolicy.trim(it).isNotEmpty() } ?: user?.pubkeyDisplayHex() ?: pubkey.take(8), + isAgent = owner != null || hasAgentProfile(pubkey), + ownerPubkey = owner, + ) + } + + /** Whether the cache holds a kind-10100 agent profile by [pubkey]. */ + fun hasCachedAgentProfile(pubkey: HexKey): Boolean = LocalCache.getAddressableNoteIfExists(AgentProfileEvent.createAddress(pubkey))?.event is AgentProfileEvent + + private fun resolve( + candidates: Set, + viewer: HexKey, + users: (HexKey) -> User?, + hasAgentProfile: (HexKey) -> Boolean, + ): Map { + val facts = candidates.filter { isKey(it) }.map { factFor(it, users, hasAgentProfile) } + // Owners outside the channel only lend their names ("Alice’s Honey"); they don't compete. + // Listed first so a member's own fact stays the preferred one. + val ownerFacts = + facts + .mapNotNull { it.ownerPubkey } + .filter { it !in candidates } + .distinct() + .mapNotNull { owner -> + users(owner) + ?.metadataOrNull() + ?.flow + ?.value + ?.info + ?.bestName() + ?.takeIf { IdentityNamePolicy.trim(it).isNotEmpty() } + ?.let { NamingIdentity(owner, it) } + } + return IdentityNamePolicy.resolve(ownerFacts + facts, viewer = viewer.takeIf { isKey(it) }, candidates = facts.map { it.pubkey }) + } + + private fun isKey(value: String) = value.length == 64 && value.all { it in '0'..'9' || it in 'a'..'f' || it in 'A'..'F' } + + /** Test-only. */ + fun clearForTesting() = + lock.withLock { + cache.clear() + mutableVersion.value = 0 + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/buzz/BuzzWorkspaceStates.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/buzz/BuzzWorkspaceStates.kt index 9876820844..73d68e7830 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/buzz/BuzzWorkspaceStates.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/buzz/BuzzWorkspaceStates.kt @@ -23,6 +23,7 @@ package com.vitorpamplona.amethyst.commons.model.buzz import com.vitorpamplona.amethyst.commons.model.Note import com.vitorpamplona.amethyst.commons.util.KmpLock import com.vitorpamplona.amethyst.commons.util.withLock +import com.vitorpamplona.quartz.buzz.stream.CanvasEvent import com.vitorpamplona.quartz.utils.cache.LargeCache import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -46,7 +47,10 @@ import kotlin.concurrent.Volatile class BuzzWorkspaceState { private val lock = KmpLock() - /** The newest canvas (kind 40100) note for this channel, or null when none seen. */ + /** + * The live canvas (kind 40100) note for this channel, or null when none seen — picked the way + * the relay reads its head: `created_at DESC, id ASC` (see [CanvasEvent.isNewerHead]). + */ @Volatile var canvasNote: Note? = null private set @@ -58,7 +62,10 @@ class BuzzWorkspaceState { fun updateCanvas(note: Note) = lock.withLock { - if ((note.createdAt() ?: 0L) > (canvasNote?.createdAt() ?: 0L)) { + val current = canvasNote + // A same-second tie goes to the smallest id, not the last arrival, so every client + // (and the relay's own CAS check) agrees on which revision is the head. + if (CanvasEvent.isNewerHead(note.createdAt() ?: 0L, note.idHex, current?.createdAt(), current?.idHex)) { canvasNote = note canvasVersion.value = canvasVersion.value + 1 } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/cache/EventCache.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/cache/EventCache.kt index c53f0d9858..eab486d0d5 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/cache/EventCache.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/cache/EventCache.kt @@ -36,6 +36,8 @@ import com.vitorpamplona.amethyst.commons.model.UserContext import com.vitorpamplona.amethyst.commons.model.backups.LocallySignedEvents import com.vitorpamplona.amethyst.commons.model.buzz.BuzzCommunityMembership import com.vitorpamplona.amethyst.commons.model.buzz.BuzzDmRegistry +import com.vitorpamplona.amethyst.commons.model.buzz.BuzzHuddleLivenessState +import com.vitorpamplona.amethyst.commons.model.buzz.BuzzIdentityNames import com.vitorpamplona.amethyst.commons.model.buzz.BuzzPresenceState import com.vitorpamplona.amethyst.commons.model.buzz.BuzzRelayDialect import com.vitorpamplona.amethyst.commons.model.buzz.BuzzTypingState @@ -64,7 +66,10 @@ import com.vitorpamplona.quartz.buzz.agentProfiles.AgentProfileEvent import com.vitorpamplona.quartz.buzz.amTurnMetrics.AgentTurnMetricEvent import com.vitorpamplona.quartz.buzz.aoObserver.ObserverFrameEvent import com.vitorpamplona.quartz.buzz.apPersonas.PersonaEvent +import com.vitorpamplona.quartz.buzz.arArtifacts.ArtifactEvent +import com.vitorpamplona.quartz.buzz.arArtifacts.ArtifactRemovalEvent import com.vitorpamplona.quartz.buzz.audit.AuditEntryEvent +import com.vitorpamplona.quartz.buzz.cwChannelWindow.ThreadWindowBoundsEvent import com.vitorpamplona.quartz.buzz.cwChannelWindow.WindowBoundsEvent import com.vitorpamplona.quartz.buzz.dm.DmAddMemberEvent import com.vitorpamplona.quartz.buzz.dm.DmCreatedEvent @@ -77,6 +82,7 @@ import com.vitorpamplona.quartz.buzz.forum.ForumPostEvent import com.vitorpamplona.quartz.buzz.forum.ForumVoteEvent import com.vitorpamplona.quartz.buzz.huddles.HuddleEndedEvent import com.vitorpamplona.quartz.buzz.huddles.HuddleGuidelinesEvent +import com.vitorpamplona.quartz.buzz.huddles.HuddleLivenessEvent import com.vitorpamplona.quartz.buzz.huddles.HuddleParticipantJoinedEvent import com.vitorpamplona.quartz.buzz.huddles.HuddleParticipantLeftEvent import com.vitorpamplona.quartz.buzz.huddles.HuddleReactionEvent @@ -98,6 +104,7 @@ import com.vitorpamplona.quartz.buzz.moderation.ModerationResolveReportEvent import com.vitorpamplona.quartz.buzz.moderation.ModerationTimeoutEvent import com.vitorpamplona.quartz.buzz.moderation.ModerationUntimeoutEvent import com.vitorpamplona.quartz.buzz.moderation.ProductFeedbackEvent +import com.vitorpamplona.quartz.buzz.mpProjects.ProjectEvent import com.vitorpamplona.quartz.buzz.notifications.MemberAddedNotificationEvent import com.vitorpamplona.quartz.buzz.notifications.MemberRemovedNotificationEvent import com.vitorpamplona.quartz.buzz.pairing.PairingEvent @@ -119,6 +126,7 @@ import com.vitorpamplona.quartz.buzz.stream.SystemMessageEvent import com.vitorpamplona.quartz.buzz.stream.SystemMessagePayload import com.vitorpamplona.quartz.buzz.stream.sidecars.ChannelSummaryEvent import com.vitorpamplona.quartz.buzz.stream.sidecars.PresenceSnapshotEvent +import com.vitorpamplona.quartz.buzz.teamCatalog.TeamCatalogEvent import com.vitorpamplona.quartz.buzz.teams.TeamEvent import com.vitorpamplona.quartz.buzz.threading.buzzThreadReply import com.vitorpamplona.quartz.buzz.workflow.ApprovalDenyEvent @@ -1098,6 +1106,7 @@ open class EventCache : val newUserMetadata = event.contactMetaData() if (newUserMetadata != null && (wasVerified || justVerify(event))) { user.updateUserInfo(newUserMetadata, event) + BuzzIdentityNames.invalidate() if (relay != null) { user.addRelayBeingUsed(relay, event.createdAt) } @@ -2329,7 +2338,7 @@ open class EventCache : /** * Marks the serving relay as Buzz, but only off a VERIFIED event: the mark changes - * what the composer sends (40002 vs kind 9) and how new channels on the relay are + * what the composer sends (Buzz's kind-9 shape vs NIP-29's) and how new channels on the relay are * treated, so an unverifiable frame from a buggy/hostile relay must not flip it. * The note-has-event check is the same verification gate the attach path uses. */ @@ -3757,6 +3766,11 @@ open class EventCache : is AuditEntryEvent, is ChannelSummaryEvent, is PresenceSnapshotEvent, + // NIP-AR artifact revisions and relay-signed removal markers: queryable state, never + // chat rows - the spec keeps them out of chat, reply and unread counts. The current + // head per `d` is derived with ArtifactHeadResolver (by `prev`, not by time). + is ArtifactEvent, + is ArtifactRemovalEvent, -> consumeBuzzRegularEvent(event, relay, wasVerified) // Buzz ephemeral signals: transient by definition (20000-29999) — do not @@ -3776,8 +3790,19 @@ open class EventCache : BuzzPresenceState.record(event.subjectPubKey(), event.status(), event.createdAt) false } + // An agent profile makes its author an agent, which can change how names in a + // channel are told apart. + is AgentProfileEvent -> consumeBaseReplaceable(event, relay, wasVerified).also { BuzzIdentityNames.invalidate() } is ObserverFrameEvent -> false is HuddleReactionEvent -> false + // Relay-synthesized "this huddle session is live" (48104): only produced on demand + // for a dedicated REQ and never stored, so record it and keep no note. + is HuddleLivenessEvent -> { + val channel = event.channelId() + val session = event.sessionId() + if (channel != null && session != null) BuzzHuddleLivenessState.record(channel, session, TimeUtils.now()) + false + } // Pairing (24134) is deliberately dialect-neutral: it flows during device // pairing before any workspace relationship is established. is PairingEvent -> false @@ -3900,13 +3925,15 @@ open class EventCache : // Buzz addressable/replaceable state. is PersonaEvent, is TeamEvent, + is TeamCatalogEvent, + is ProjectEvent, is ManagedAgentEvent, - is AgentProfileEvent, is EngramEvent, is WorkflowDefEvent, is EventReminderEvent, is PushLeaseEvent, is WindowBoundsEvent, + is ThreadWindowBoundsEvent, is ArchivedIdentitiesListEvent, is RelayDiscoveryEvent, is RelayMonitorEvent, diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/chats/MinichatReply.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/chats/MinichatReply.kt index 1e97f7e0a5..51976d5299 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/chats/MinichatReply.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/chats/MinichatReply.kt @@ -21,6 +21,7 @@ package com.vitorpamplona.amethyst.commons.model.chats import com.vitorpamplona.quartz.buzz.stream.StreamMessageV2Event +import com.vitorpamplona.quartz.buzz.stream.isBroadcast import com.vitorpamplona.quartz.buzz.threading.buzzThreadReply import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip22Comments.CommentEvent @@ -31,10 +32,11 @@ import com.vitorpamplona.quartz.nipC7Chats.ChatEvent * its parent message, NOT as a flat sibling in the main timeline. Three dialects express the same idea: * * - **NIP-28/NIP-29 (public chats, Concord)**: a kind-1111 [CommentEvent]. - * - **Buzz workspaces, current**: a kind-9 [ChatEvent] carrying a NIP-10 `reply`-marked `e` tag. This - * is what every live Buzz client writes — `_buildReplyTags` in its Flutter client emits - * `["e", id, "", "reply"]` for a direct reply and `["e", root, "", "root"]` + - * `["e", parent, "", "reply"]` for a nested one, and all three of their clients send chat as kind 9. + * - **Buzz workspaces, current**: a kind-9 [ChatEvent] carrying a NIP-10 `reply`-marked `e` tag and + * NOT flagged `broadcast`. This is what every live Buzz client (and Amethyst) writes — + * `build_message` emits `["e", id, "", "reply"]` for a direct reply and `["e", root, "", "root"]` + + * `["e", parent, "", "reply"]` for a nested one, plus `["broadcast", "1"]` when the reply should + * also show in the channel. * - **Buzz workspaces, legacy**: a kind-40002 [StreamMessageV2Event] with the same markers and NOT * flagged `broadcast`. Nothing in Buzz writes 40002 any more (their own NOSTR.md grades it * "Buzz-only — no standard NIP-29 client renders these"), but events exist in the wild from the @@ -49,8 +51,8 @@ import com.vitorpamplona.quartz.nipC7Chats.ChatEvent * as a quote bubble in the timeline — so matching on the `reply` marker (never on the bare tag) leaves * that dialect untouched. * - * A `broadcast=1` reply is an inline timeline sibling ("also send to channel"), matching block/buzz's - * `isThreadReply`. Kind 9 has no broadcast tag, so a marked kind-9 is always thread-only. + * A `broadcast=1` reply is an inline timeline sibling ("also send to channel") on either kind, matching + * block/buzz's `isBroadcastReply`, which the relay reads the same way whatever the kind. * * The timeline filter drops these (they belong in the minichat), the minichat count counts them, and * the minichat feed shows them — so all three agree on one definition. @@ -58,7 +60,7 @@ import com.vitorpamplona.quartz.nipC7Chats.ChatEvent fun isMinichatReply(event: Event?): Boolean = when (event) { is CommentEvent -> true - is ChatEvent -> event.tags.buzzThreadReply() != null + is ChatEvent -> !event.tags.isBroadcast() && event.tags.buzzThreadReply() != null is StreamMessageV2Event -> !event.isBroadcast() && event.tags.buzzThreadReply() != null else -> false } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip01Core/UserMetadataCache.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip01Core/UserMetadataCache.kt index ffa1e25374..59cea6acab 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip01Core/UserMetadataCache.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip01Core/UserMetadataCache.kt @@ -23,6 +23,8 @@ package com.vitorpamplona.amethyst.commons.model.nip01Core import androidx.compose.runtime.Stable import com.vitorpamplona.amethyst.commons.model.ImmutableListOfLists import com.vitorpamplona.amethyst.commons.model.toImmutableListOfLists +import com.vitorpamplona.quartz.buzz.oaOwnerAttestation.OwnerAttestation +import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent import com.vitorpamplona.quartz.nip01Core.metadata.UserMetadata import com.vitorpamplona.quartz.nip39ExtIdentities.IdentityClaimTag @@ -37,7 +39,17 @@ class UserInfo( val tags: ImmutableListOfLists, val identities: List, val createdAt: Long, -) + val pubKey: HexKey? = null, +) { + /** + * The owner this profile declares through a valid NIP-OA `auth` tag, making the user an agent + * of that owner (who may then edit the agent's Buzz messages), or null. Verified once per + * profile version. + */ + val nipOaOwner: HexKey? by lazy { + pubKey?.let { OwnerAttestation.verifiedOwnerOf(it, MetadataEvent.KIND, createdAt, tags.lists) } + } +} @Stable class UserMetadataCache { @@ -53,6 +65,7 @@ class UserMetadataCache { tags = metaEvent.tags.toImmutableListOfLists(), identities = metaEvent.identityClaims(), createdAt = metaEvent.createdAt, + pubKey = metaEvent.pubKey, ) } } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip29RelayGroups/RelayGroupChannel.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip29RelayGroups/RelayGroupChannel.kt index 0b7bd453ee..49389c1958 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip29RelayGroups/RelayGroupChannel.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip29RelayGroups/RelayGroupChannel.kt @@ -24,6 +24,7 @@ import androidx.compose.runtime.Stable import com.vitorpamplona.amethyst.commons.model.Channel import com.vitorpamplona.amethyst.commons.model.Note import com.vitorpamplona.amethyst.commons.model.buzz.BuzzCommunityMembership +import com.vitorpamplona.amethyst.commons.model.buzz.BuzzIdentityNames import com.vitorpamplona.amethyst.commons.model.buzz.BuzzRelayDialect import com.vitorpamplona.amethyst.commons.util.KmpLock import com.vitorpamplona.amethyst.commons.util.withLock @@ -139,6 +140,8 @@ class RelayGroupChannel( private fun recomputeAllMembers() { allMembers = if (admins.isEmpty()) members else members + admins.mapTo(HashSet()) { it.pubKey } + // Who is in the channel decides which names collide there. + if (BuzzRelayDialect.isBuzz(groupId.relayUrl)) BuzzIdentityNames.invalidate() } /** @@ -316,6 +319,9 @@ class RelayGroupChannel( /** Number of known members (admins are members too). */ fun memberCount(): Int = allMembers.size + /** Every known member and admin. The set is replaced, never mutated, when the roster changes. */ + fun allMemberKeys(): Set = allMembers + /** * The subset of this group's members/admins that [follows] contains — "people you follow who * are in here". Reads the cached [allMembers] set, so it's cheap to call per recomposition. diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip38UserStatuses/UserStatusCache.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip38UserStatuses/UserStatusCache.kt index 6f8ebac80e..fc04d0352a 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip38UserStatuses/UserStatusCache.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip38UserStatuses/UserStatusCache.kt @@ -23,6 +23,7 @@ package com.vitorpamplona.amethyst.commons.model.nip38UserStatuses import com.vitorpamplona.amethyst.commons.model.AddressableNote import com.vitorpamplona.amethyst.commons.model.Note import com.vitorpamplona.amethyst.commons.model.UserDependencies +import com.vitorpamplona.quartz.nip38UserStatus.UserStatusEvent import com.vitorpamplona.quartz.nip40Expiration.expiration import com.vitorpamplona.quartz.nip40Expiration.isExpired import kotlinx.collections.immutable.ImmutableList @@ -42,7 +43,7 @@ class UserStatusCache : UserDependencies { fun addStatus(note: AddressableNote) { // if it's already there, quick exit - if (statuses.value.contains(note) || note.event?.content.isNullOrBlank()) return + if (statuses.value.contains(note) || note.isEmptyStatus()) return // don't add expired statuses if (note.event?.isExpired() == true) return @@ -52,6 +53,15 @@ class UserStatusCache : UserDependencies { } } + /** + * Nothing to show: no event, or blank text with no plain status emoji. An emoji-only status + * (Buzz writes `["emoji", "🌴"]` with blank content) is still a status. + */ + private fun Note.isEmptyStatus(): Boolean { + val event = event ?: return true + return if (event is UserStatusEvent) event.isCleared() else event.content.isBlank() + } + fun removeStatus(deleteNote: AddressableNote) { // if it's not already there, quick exit if (!statuses.value.contains(deleteNote)) return diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/user/watchers/FilterUserMetadataForKey.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/user/watchers/FilterUserMetadataForKey.kt index bab6f362a6..ec83f0f2d2 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/user/watchers/FilterUserMetadataForKey.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/user/watchers/FilterUserMetadataForKey.kt @@ -25,6 +25,7 @@ import com.vitorpamplona.amethyst.commons.relayClient.event.loaders.forEachChunk import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose import com.vitorpamplona.amethyst.commons.relays.EOSEAccountFast +import com.vitorpamplona.quartz.buzz.agentProfiles.AgentProfileEvent import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageRelayListEvent import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.hints.HintIndexer @@ -57,6 +58,10 @@ val UserMetadataForKeyKinds = // kind:17375 — that's the user's private wallet (NIP-44 encrypted // to them); only the kind:10019 nutzap announcement is public. NutzapInfoEvent.KIND, + // Buzz agent profile (kind:10100): marks the author as an agent and carries its metadata + // and channel-add policy. Co-loaded with kind:0 so an agent is recognizable the moment it + // renders. (Its owner comes from the NIP-OA `auth` tag on the kind:0 itself.) + AgentProfileEvent.KIND, ) fun filterUserMetadataForKey( diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/BuzzRoleForTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/BuzzRoleForTest.kt new file mode 100644 index 0000000000..3588256fef --- /dev/null +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/BuzzRoleForTest.kt @@ -0,0 +1,42 @@ +/* + * 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.amethyst.commons.model + +import kotlin.test.Test +import kotlin.test.assertEquals + +class BuzzRoleForTest { + @Test + fun picksTheMostPrivilegedBuzzRole() { + assertEquals("owner", buzzRoleFor(listOf("admin", "owner"))) + assertEquals("admin", buzzRoleFor(listOf("admin"))) + assertEquals("admin", buzzRoleFor(listOf("Admin"))) + assertEquals("bot", buzzRoleFor(listOf("bot"))) + assertEquals("guest", buzzRoleFor(listOf("guest"))) + } + + @Test + fun unknownOrEmptyRolesBecomeMember() { + // Buzz has no moderator; an unparseable role would fail the whole put-user. + assertEquals("member", buzzRoleFor(listOf("moderator"))) + assertEquals("member", buzzRoleFor(emptyList())) + } +} diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/buzz/AgentFleetAggregatorTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/buzz/AgentFleetAggregatorTest.kt index 6765377150..44bc72504c 100644 --- a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/buzz/AgentFleetAggregatorTest.kt +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/buzz/AgentFleetAggregatorTest.kt @@ -25,6 +25,7 @@ import com.vitorpamplona.quartz.buzz.amTurnMetrics.TokenCounts import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse +import kotlin.test.assertNull import kotlin.test.assertTrue class AgentFleetAggregatorTest { @@ -196,4 +197,67 @@ class AgentFleetAggregatorTest { assertEquals(50L, metrics.totals.totalTokens) assertFalse(metrics.hasUnreliableEstimates) } + + private fun cache( + read: Long? = null, + write: Long? = null, + ) = TokenCounts(inputTokens = 100, cacheReadTokens = read, cacheWriteTokens = write) + + /** NIP-AM: an omitted cache component is unknown — the total must not become a fabricated 0. */ + @Test + fun unreportedCacheStaysUnknown() { + val metrics = + AgentFleetAggregator.aggregate( + listOf( + turn(agentA, "s1", 1, turn = counts(input = 100)), + turn(agentA, "s1", 2, turn = counts(input = 100)), + ), + ) + assertNull(metrics.totals.cacheReadTokens) + assertNull(metrics.totals.cacheWriteTokens) + assertEquals(200L, metrics.totals.inputTokens) + } + + @Test + fun explicitZeroCacheIsKnownZero() { + val metrics = AgentFleetAggregator.aggregate(listOf(turn(agentA, "s1", 1, turn = cache(read = 0, write = 0)))) + assertEquals(0L, metrics.totals.cacheReadTokens) + assertEquals(0L, metrics.totals.cacheWriteTokens) + } + + @Test + fun cacheUsesMaxCumulativeThenReportedDeltas() { + val metrics = + AgentFleetAggregator.aggregate( + listOf( + // Session s1 reports cumulatively; s2 only per turn, and only for reads. + turn(agentA, "s1", 1, cumulative = cache(read = 40, write = 5)), + turn(agentA, "s1", 2, cumulative = cache(read = 90, write = 5)), + turn(agentA, "s2", 3, turn = cache(read = 7)), + turn(agentA, "s2", 4, turn = cache(read = 3)), + // Another agent that never reports cache at all. + turn(agentB, "s3", 5, turn = counts(input = 1)), + ), + ) + val a = metrics.agents.single { it.agentPubKey == agentA } + assertEquals(100L, a.totals.cacheReadTokens) + assertEquals(5L, a.totals.cacheWriteTokens) + + val b = metrics.agents.single { it.agentPubKey == agentB } + assertNull(b.totals.cacheReadTokens) + + // Unknown sessions add nothing to the fleet total, but don't erase the known ones. + assertEquals(100L, metrics.totals.cacheReadTokens) + assertEquals(5L, metrics.totals.cacheWriteTokens) + } + + @Test + fun tokenTotalsAddUnknownAsIdentity() { + val known = TokenTotals(cacheReadTokens = 3) + val unknown = TokenTotals() + assertEquals(3L, (known + unknown).cacheReadTokens) + assertEquals(3L, (unknown + known).cacheReadTokens) + assertNull((unknown + unknown).cacheReadTokens) + assertEquals(6L, (known + known).cacheReadTokens) + } } diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/buzz/BuzzCanvasHeadTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/buzz/BuzzCanvasHeadTest.kt new file mode 100644 index 0000000000..31032f5134 --- /dev/null +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/buzz/BuzzCanvasHeadTest.kt @@ -0,0 +1,100 @@ +/* + * 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.amethyst.commons.model.buzz + +import com.vitorpamplona.amethyst.commons.model.Note +import com.vitorpamplona.quartz.buzz.stream.CanvasEvent +import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.PublishResult +import kotlin.test.AfterTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertSame + +class BuzzCanvasHeadTest { + private val channel = "3f2504e0-4f89-41d3-9a0c-0305e82c3301" + + private fun canvas( + id: String, + createdAt: Long, + ): Note = + Note(id).apply { + event = CanvasEvent(id, "a".repeat(64), createdAt, arrayOf(arrayOf("h", channel)), "rev $id", "sig") + } + + @AfterTest + fun tearDown() = BuzzWorkspaceStates.clearForTesting() + + @Test + fun newerCreatedAtWins() { + val state = BuzzWorkspaceState() + val old = canvas("1".repeat(64), 100) + val new = canvas("2".repeat(64), 101) + state.updateCanvas(new) + state.updateCanvas(old) + assertSame(new, state.canvasNote) + } + + /** The relay reads the head as `created_at DESC, id ASC`: a same-second tie goes to the smaller id. */ + @Test + fun sameSecondTieGoesToTheSmallestIdNotTheLastArrival() { + val small = canvas("1".repeat(64), 100) + val big = canvas("f".repeat(64), 100) + + val bigFirst = BuzzWorkspaceState() + bigFirst.updateCanvas(big) + bigFirst.updateCanvas(small) + assertSame(small, bigFirst.canvasNote) + + val smallFirst = BuzzWorkspaceState() + smallFirst.updateCanvas(small) + smallFirst.updateCanvas(big) + assertSame(small, smallFirst.canvasNote) + assertEquals(1, smallFirst.canvasUpdates.value) + } + + @Test + fun reconsumingTheHeadDoesNotBump() { + val state = BuzzWorkspaceState() + val head = canvas("1".repeat(64), 100) + state.updateCanvas(head) + state.updateCanvas(head) + assertEquals(1, state.canvasUpdates.value) + } + + @Test + fun writerClassifiesRelayAnswers() { + val event = CanvasEvent("1".repeat(64), "a".repeat(64), 100, arrayOf(arrayOf("h", channel)), "x", "sig") + + assertIs(BuzzCanvasWriter.classify(event, listOf(PublishResult(true, "")))) + + val conflict = BuzzCanvasWriter.classify(event, listOf(PublishResult(false, "conflict: canvas changed since it was loaded"))) + assertIs(conflict) + assertEquals("conflict: canvas changed since it was loaded", conflict.message) + + val invalid = BuzzCanvasWriter.classify(event, listOf(PublishResult(false, "invalid: canvas created_at too far in the future"))) + assertIs(invalid) + + val silent = BuzzCanvasWriter.classify(event, emptyList()) + assertIs(silent) + assertEquals(PublishResult.NO_RESPONSE, silent.message) + } +} diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/buzz/BuzzHuddleLivenessStateTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/buzz/BuzzHuddleLivenessStateTest.kt new file mode 100644 index 0000000000..0cdd43bbd8 --- /dev/null +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/buzz/BuzzHuddleLivenessStateTest.kt @@ -0,0 +1,50 @@ +/* + * 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.amethyst.commons.model.buzz + +import kotlin.test.AfterTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class BuzzHuddleLivenessStateTest { + @AfterTest + fun reset() = BuzzHuddleLivenessState.clearForTesting() + + @Test + fun aReportedSessionIsLiveUntilItStopsBeingReported() { + BuzzHuddleLivenessState.record("chan", "s1", seenAtSecs = 1_000) + + assertEquals(setOf("s1"), BuzzHuddleLivenessState.liveSessions("chan", nowSecs = 1_000)) + assertEquals(setOf("s1"), BuzzHuddleLivenessState.liveSessions("chan", nowSecs = 1_000 + BuzzHuddleLivenessState.LIVE_WINDOW_SECS)) + // Two missed 10s polls later it is gone: the relay only answers for live sessions. + assertTrue(BuzzHuddleLivenessState.liveSessions("chan", nowSecs = 1_001 + BuzzHuddleLivenessState.LIVE_WINDOW_SECS).isEmpty()) + } + + @Test + fun sessionsAreScopedToTheirChannelAndAnOlderReportIsIgnored() { + BuzzHuddleLivenessState.record("chan", "s1", seenAtSecs = 1_000) + BuzzHuddleLivenessState.record("chan", "s1", seenAtSecs = 900) + + assertTrue(BuzzHuddleLivenessState.liveSessions("other", nowSecs = 1_000).isEmpty()) + assertEquals(1_000L, BuzzHuddleLivenessState.flow.value["chan"]?.get("s1")) + } +} diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/buzz/BuzzIdentityNamesTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/buzz/BuzzIdentityNamesTest.kt new file mode 100644 index 0000000000..2be7ec3f7d --- /dev/null +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/buzz/BuzzIdentityNamesTest.kt @@ -0,0 +1,104 @@ +/* + * 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.amethyst.commons.model.buzz + +import com.vitorpamplona.amethyst.commons.model.AddressableNote +import com.vitorpamplona.amethyst.commons.model.User +import com.vitorpamplona.amethyst.commons.model.UserContext +import com.vitorpamplona.amethyst.commons.model.nip01Core.UserInfo +import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel +import com.vitorpamplona.amethyst.commons.model.toImmutableListOfLists +import com.vitorpamplona.quartz.buzz.oaOwnerAttestation.OwnerAttestation +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.metadata.UserMetadata +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import com.vitorpamplona.quartz.nip29RelayGroups.GroupId +import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupMembersEvent +import kotlin.test.AfterTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class BuzzIdentityNamesTest { + private val relay = RelayUrlNormalizer.normalizeOrNull("wss://buzz.example.team/")!! + private val context = UserContext { AddressableNote(it) } + private val users = HashMap() + + @AfterTest + fun reset() = BuzzIdentityNames.clearForTesting() + + private fun user( + pubkey: HexKey, + name: String, + vararg tags: Array, + ) = User(pubkey, context).also { + it.metadata().flow.value = UserInfo(UserMetadata().apply { this.name = name }, arrayOf(*tags).toImmutableListOfLists(), emptyList(), 1, pubkey) + users[pubkey] = it + } + + private fun channelWith(vararg members: HexKey) = + RelayGroupChannel(GroupId("6a39da2f-33c0-44f6-a050-c4da0138644a", relay)).also { channel -> + channel.updateMembers(GroupMembersEvent("00", "ff".repeat(32), 10, members.map { arrayOf("p", it) }.toTypedArray(), "", "00")) + } + + private fun label( + channel: RelayGroupChannel, + pubkey: HexKey, + viewer: HexKey, + ) = BuzzIdentityNames.labelFor(channel, pubkey, viewer, users = { users[it] }, hasAgentProfile = { false })?.name + + @Test + fun anAgentSharingAMembersNameIsNamedAfterItsOwner() { + val alice = KeyPair() + val aliceKey = alice.pubKey.toHexKey() + val me = "1".repeat(64) + val human = "c".repeat(64) + val agent = "a".repeat(64) + user(me, "Me") + user(aliceKey, "Alice") + user(human, "Honey") + user(agent, "Honey", OwnerAttestation.sign(agent, "", alice.privKey!!).toTag()) + + val channel = channelWith(me, human, agent) + // The person keeps the plain name; the agent borrows its owner's. + assertEquals("Honey", label(channel, human, me)) + assertEquals("Alice’s Honey", label(channel, agent, me)) + } + + @Test + fun namesOnlyGrowOnARealCollision() { + val me = "1".repeat(64) + val a = "a".repeat(64) + val b = "b".repeat(64) + user(me, "Me") + user(a, "Sam") + user(b, "Sam") + + // Alone in the channel, Sam is just Sam. + assertEquals("Sam", label(channelWith(me, a), a, me)) + // With a namesake, both get a key suffix. + val both = channelWith(me, a, b) + assertTrue(label(both, a, me)!!.startsWith("Sam · ")) + assertTrue(label(both, a, me) != label(both, b, me)) + } +} diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/chats/MinichatReplyTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/chats/MinichatReplyTest.kt index 27e0a33c10..1bb8443dee 100644 --- a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/chats/MinichatReplyTest.kt +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/chats/MinichatReplyTest.kt @@ -96,4 +96,10 @@ class MinichatReplyTest { fun `root marker alone is not a reply`() { assertFalse(isMinichatReply(chat(arrayOf("e", rootId, "", "root")))) } + + /** "Also send to channel": Buzz flags the reply `broadcast`, on kind 9 exactly as on 40002. */ + @Test + fun `buzz broadcast reply stays in the channel timeline`() { + assertFalse(isMinichatReply(chat(arrayOf("e", parentId, "", "reply"), arrayOf("broadcast", "1")))) + } } diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/nip01Core/UserInfoAgentOwnerTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/nip01Core/UserInfoAgentOwnerTest.kt new file mode 100644 index 0000000000..ab5868a955 --- /dev/null +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/nip01Core/UserInfoAgentOwnerTest.kt @@ -0,0 +1,55 @@ +/* + * 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.amethyst.commons.model.nip01Core + +import com.vitorpamplona.amethyst.commons.model.toImmutableListOfLists +import com.vitorpamplona.quartz.buzz.oaOwnerAttestation.OwnerAttestation +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.metadata.UserMetadata +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +class UserInfoAgentOwnerTest { + private val owner = KeyPair() + private val agent = KeyPair().pubKey.toHexKey() + + private fun info( + vararg tags: Array, + pubKey: String? = agent, + ) = UserInfo(UserMetadata(), arrayOf(*tags).toImmutableListOfLists(), emptyList(), createdAt = 1_000, pubKey = pubKey) + + @Test + fun aProfileWithAValidAttestationNamesItsOwner() { + val tag = OwnerAttestation.sign(agent, "", owner.privKey!!).toTag() + assertEquals(owner.pubKey.toHexKey(), info(tag).nipOaOwner) + } + + @Test + fun noAttestationOrOneForAnotherKeyMeansNoOwner() { + assertNull(info().nipOaOwner) + val forSomeoneElse = OwnerAttestation.sign(KeyPair().pubKey.toHexKey(), "", owner.privKey!!).toTag() + assertNull(info(forSomeoneElse).nipOaOwner) + // Without the profile's own pubkey there is nothing to verify against. + assertNull(info(OwnerAttestation.sign(agent, "", owner.privKey!!).toTag(), pubKey = null).nipOaOwner) + } +} diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/relayClient/user/watchers/UserMetadataForKeyKindsTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/relayClient/user/watchers/UserMetadataForKeyKindsTest.kt new file mode 100644 index 0000000000..20700d981b --- /dev/null +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/relayClient/user/watchers/UserMetadataForKeyKindsTest.kt @@ -0,0 +1,34 @@ +/* + * 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.amethyst.commons.relayClient.user.watchers + +import com.vitorpamplona.quartz.buzz.agentProfiles.AgentProfileEvent +import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent +import kotlin.test.Test +import kotlin.test.assertTrue + +class UserMetadataForKeyKindsTest { + @Test + fun agentProfilesLoadWithEveryUserProfile() { + assertTrue(MetadataEvent.KIND in UserMetadataForKeyKinds) + assertTrue(AgentProfileEvent.KIND in UserMetadataForKeyKinds) + } +} diff --git a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/actions/BuzzInviteMinter.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/actions/BuzzInviteMinter.kt index a4d1ed1c5b..6db364a911 100644 --- a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/actions/BuzzInviteMinter.kt +++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/actions/BuzzInviteMinter.kt @@ -45,26 +45,47 @@ import okhttp3.coroutines.executeAsync * `crates/buzz-relay/src/api/invites.rs`. */ object BuzzInviteMinter { - /** A freshly minted invite: the opaque [code], the shareable [url], and its [expiresAt] (secs). */ + /** Shortest lifetime the relay accepts (`MIN_INVITE_TTL_SECS` in `buzz-core/src/invite.rs`). */ + const val MIN_TTL_SECS = 60L + + /** Longest lifetime the relay accepts: 30 days (`MAX_INVITE_TTL_SECS`). */ + const val MAX_TTL_SECS = 30L * 24 * 60 * 60 + + /** Largest `max_uses` the relay accepts (`MAX_INVITE_USES`, the database constraint). */ + const val MAX_USES = 10_000 + + /** + * A freshly minted invite: the opaque [code], the shareable [url], and its [expiresAt] (secs). + * [maxUses] / [usesRemaining] are null for an unlimited invite (the default). + */ data class MintedInvite( val code: String, val url: String, val expiresAt: Long, + val maxUses: Int? = null, + val usesRemaining: Int? = null, ) /** - * POST `/api/invites` on [relay]'s host with an optional [ttlSecs] (relay clamps to [60, 30d]; - * default 72 h). [httpAuth] signs the NIP-98 event over the exact URL + body; [okHttpClient] - * supplies the transport (use a trusted-relay-posture client so a Cloudflare-fronted relay is - * reached over clearnet). Throws [IllegalStateException] with the relay's error slug on failure. + * POST `/api/invites` on [relay]'s host with an optional [ttlSecs] (default 72 h) and an + * optional [maxUses] cap (default unlimited). The relay does **not** clamp: a [ttlSecs] outside + * [[MIN_TTL_SECS], [MAX_TTL_SECS]] or a [maxUses] outside [1, [MAX_USES]] is refused with a 400, + * so both are checked here first. [httpAuth] signs the NIP-98 event over the exact URL + body; + * [okHttpClient] supplies the transport (use a trusted-relay-posture client so a + * Cloudflare-fronted relay is reached over clearnet). Throws [IllegalStateException] with the + * relay's error message on failure. */ suspend fun mint( relay: NormalizedRelayUrl, ttlSecs: Long?, okHttpClient: (String) -> OkHttpClient, httpAuth: suspend (url: String, method: String, body: ByteArray?) -> HTTPAuthorizationEvent, + maxUses: Int? = null, ): MintedInvite = withContext(Dispatchers.IO) { + require(ttlSecs == null || ttlSecs in MIN_TTL_SECS..MAX_TTL_SECS) { "ttl_secs must be between $MIN_TTL_SECS and $MAX_TTL_SECS" } + require(maxUses == null || maxUses in 1..MAX_USES) { "max_uses must be between 1 and $MAX_USES" } + // wss://host[/..] -> https://host ; ws://host -> http://host. The endpoint is host-root. val wsUrl = relay.url val scheme = if (wsUrl.startsWith("wss", ignoreCase = true)) "https" else "http" @@ -75,7 +96,7 @@ object BuzzInviteMinter { val url = httpUrl.toString() // Exact bytes the NIP-98 payload hash is computed over — must equal what we send. - val bodyStr = ttlSecs?.let { "{\"ttl_secs\":$it}" } ?: "{}" + val bodyStr = requestBody(ttlSecs, maxUses) val bodyBytes = bodyStr.toByteArray(Charsets.UTF_8) val auth = httpAuth(url, "POST", bodyBytes) @@ -101,9 +122,21 @@ object BuzzInviteMinter { code = tree?.get("code")?.stringOrNull().orEmpty(), url = tree?.get("url")?.stringOrNull().orEmpty(), expiresAt = tree?.get("expires_at")?.longOrNull() ?: 0L, + maxUses = tree?.get("max_uses")?.longOrNull()?.toInt(), + usesRemaining = tree?.get("uses_remaining")?.longOrNull()?.toInt(), ) } } + + /** The exact JSON body of a mint request; omitted fields take the relay's defaults (72 h, unlimited). */ + fun requestBody( + ttlSecs: Long?, + maxUses: Int?, + ): String = + buildList { + ttlSecs?.let { add("\"ttl_secs\":$it") } + maxUses?.let { add("\"max_uses\":$it") } + }.joinToString(",", prefix = "{", postfix = "}") } private fun JsonElement.stringOrNull(): String? = (this as? JsonPrimitive)?.takeIf { it.isString }?.content diff --git a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/actions/BuzzInviteMinterTest.kt b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/actions/BuzzInviteMinterTest.kt new file mode 100644 index 0000000000..5277ffc2db --- /dev/null +++ b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/actions/BuzzInviteMinterTest.kt @@ -0,0 +1,34 @@ +/* + * 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.amethyst.commons.actions + +import kotlin.test.Test +import kotlin.test.assertEquals + +class BuzzInviteMinterTest { + @Test + fun requestBodyOmitsUnsetFields() { + assertEquals("{}", BuzzInviteMinter.requestBody(null, null)) + assertEquals("""{"ttl_secs":3600}""", BuzzInviteMinter.requestBody(3600, null)) + assertEquals("""{"max_uses":5}""", BuzzInviteMinter.requestBody(null, 5)) + assertEquals("""{"ttl_secs":60,"max_uses":10000}""", BuzzInviteMinter.requestBody(60, 10_000)) + } +} diff --git a/commonsUI/src/commonMain/composeResources/values-pl-rPL/strings.xml b/commonsUI/src/commonMain/composeResources/values-pl-rPL/strings.xml index 480ed9bac4..0846b97e3d 100644 --- a/commonsUI/src/commonMain/composeResources/values-pl-rPL/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values-pl-rPL/strings.xml @@ -593,6 +593,10 @@ Unieważnić ten link? Każdy nadal posiadający ten link nie będzie mógł już dołączyć. Osoby, które już się do niej dołączyły, pozostają w społeczności. Tego nie można cofnąć. Unieważnij + To jest ostatni link zapraszający do tej społeczności. Jego cofnięcie spowoduje, że społeczność stanie się prywatna, a jej klucze zostaną odświeżone, dzięki czemu dostęp zachowają wyłącznie obecni członkowie. + Link został unieważniony. Społeczność ma teraz status prywatny, a jej klucze zostały odnowione. + Link został unieważniony. Społeczność ma teraz status prywatny, ale jej klucze nie zostały odświeżone: administrator posiadający uprawnienia do blokowania członków musi je odświeżyć. + Prywatne Nazwa społeczności zostanie ujawniona dopiero po dołączeniu Dołączenie do transmiterów tego zaproszenia, publikuje komunikaty o dołączeniu podpisane przez twoje konto i dodaje społeczność do listy. Nic nie zostanie wysłane, dopóki nie klikniesz Dołącz. Transmiter tego zaproszenia skontaktuje się: %1$s @@ -601,8 +605,19 @@ Otwórz kanał Dodaj baner Miejsce, w którym publikowane i czytane są zaszyfrowane plany tej społeczności. + Znikające wiadomości + Po upływie tego czasu nowe wiadomości we wszystkich kanałach są usuwane z urządzeń użytkowników oraz z transmiterów. Zmiana tego ustawienia nie ma wpływu na wiadomości, które zostały już wysłane. Każdy, kto ma dostęp do wiadomości, nadal może ją skopiować. + Wył. + %1$s ustawił znikające wiadomości do %2$s + %1$s wyłączył(a) funkcję znikających wiadomości + Wiadomości znikną za %1$s Ta społeczność została rozwiązana i jest tylko do odczytu. Nadal możesz przeczytać jej historię, ale nie można opublikować żadnych nowych wiadomości. To jest kanał prywatny, a ty nie posiadasz klucza dostępu do niego, więc nie możesz czytać ani publikować w nim postów. + Przypięte wiadomości + Brak przypiętych wiadomości na tym kanale. + Piny tego kanału są zabezpieczone kluczem, którego nie posiadasz, więc nie można ich tutaj wyświetlić, a przypinanie jest wstrzymane do czasu, aż będzie można je odczytać. + Pineski + Przypięta lista jest zabezpieczona kluczem, którego nie posiadasz. Jej zmiana w tej chwili spowodowałaby usunięcie przypiętych elementów, których nie widzisz. Nazwa Informacje (opcjonalne) Banuj @@ -3653,6 +3668,17 @@ Transmitery Transmitery, które przechowują zaszyfrowane wiadomości tej społeczności. Pozostaw puste, aby użyć własnych. Nowy kanał Concord + Zaakceptuj + Zaproszenie od npub… + Odrzuć + Zaproszenie jest szyfrowane dla tej osoby i dostarczane do jej transmiterów skrzynki odbiorczej. Zawiera wyłącznie kanały prywatne przypisane jej na podstawie pełnionych ról i nie można go cofnąć po wysłaniu. + To zaproszenie wygasło + Zaproszono przez %1$s + Nazwa, npub lub NIP-05 + Wyślij zaproszenie do %1$s + Zaproszenie zostało wysłane + Zaproś kogoś bezpośrednio + Zaproszenia do społeczności Edytuj społeczność Edytowanie wiadomości Kanały Concord @@ -3662,6 +3688,7 @@ Ten link do zaproszenia wygasł i nie może być już używany. Poproś o nowy link. Nie można otworzyć tego linku do zaproszenia. Być może jest on nieaktualny, został już zastąpiony nowszym lub został utworzony w nowszej wersji aplikacji. Poproś o nowy link do zaproszenia. Ten link do zaproszenia jest nieprawidłowy lub nie można go otworzyć przy użyciu tego konta. + Zaproszenie działa, ale twoja lista społeczności nie mogła zostać zaktualizowana, więc nie dołączyłeś do niej. Spróbuj ponownie za chwilę. Ten link do zaproszenia został unieważniony i nie można już z niego korzystać. Poproś o nowy. Linki z zaproszeniem Linki z zaproszeniem @@ -3680,6 +3707,8 @@ %1$d członkowie Banuj + Zbanować użytkownika? + Nie mogą już publikować postów, a ich wiadomości są ukryte przed wszystkimi. Inne aplikacje mogą usunąć tę społeczność ze swojej listy. Nadal mogą czytać nowe wiadomości, dopóki nie usuniesz ich również ze społeczności. Możesz później cofnąć im blokadę. Stwórz administratora Usuń administratora Nie udało się zaktualizować ról tego członka. diff --git a/commonsUI/src/commonMain/composeResources/values/strings.xml b/commonsUI/src/commonMain/composeResources/values/strings.xml index d147f43a6d..5233867a0e 100644 --- a/commonsUI/src/commonMain/composeResources/values/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values/strings.xml @@ -2363,6 +2363,11 @@ Edit canvas Save canvas Canvas (Markdown) + Someone else changed this canvas since you started editing. Copy your changes, reopen the canvas and apply them again. + The current canvas is timestamped too far in the future to be edited safely. + Couldn't save the canvas: %1$s + %1$s was removed by an administrator + This channel was archived automatically Editing message (edited) %1$s is typing… @@ -3158,6 +3163,12 @@ Hold an attestation Holding an attestation for this account. It is attached automatically when you authenticate to a Buzz relay. Grants: %1$s + Huddle live + Agent + managed by %1$s + managed by you + This attestation has expired. Buzz relays no longer accept it; ask the owner for a new one. + This attestation is not valid yet. Buzz relays will accept it once its start time passes. any kind, any time (unrestricted) Remove Paste an owner-signed auth tag issued to this account to authenticate to their Buzz workspace as a virtual member. @@ -3187,6 +3198,12 @@ Provider (optional) Runtime (optional) Avatar URL (optional) + Description (optional) + Public, up to 280 characters. + Share with the workspace + Other members can see and use this persona. Off keeps it to your own devices. + One conversation per thread + The agent keeps a separate conversation for each thread instead of one per channel. Publishing… Publish persona Follows you diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/buzz/ui/BuzzAgentLabel.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/buzz/ui/BuzzAgentLabel.kt new file mode 100644 index 0000000000..d7e7ceb43d --- /dev/null +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/buzz/ui/BuzzAgentLabel.kt @@ -0,0 +1,117 @@ +/* + * 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.amethyst.commons.buzz.ui + +import androidx.compose.foundation.clickable +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.produceState +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextOverflow +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.vitorpamplona.amethyst.commons.model.User +import com.vitorpamplona.amethyst.commons.model.buzz.BuzzIdentityNames +import com.vitorpamplona.amethyst.commons.model.cache.LocalCache +import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel +import com.vitorpamplona.amethyst.commons.resources.Res +import com.vitorpamplona.amethyst.commons.resources.buzz_agent_label +import com.vitorpamplona.amethyst.commons.resources.buzz_agent_managed_by +import com.vitorpamplona.amethyst.commons.resources.buzz_agent_managed_by_you +import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.feed.types.observeUserNameByHex +import com.vitorpamplona.amethyst.commons.ui.stringRes +import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel +import com.vitorpamplona.quartz.buzz.agentProfiles.AgentProfileEvent +import com.vitorpamplona.quartz.buzz.identityNames.ResolvedIdentityName +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.flow.debounce + +/** + * Whether [author] has published a Buzz agent profile (kind 10100). The profile is requested with + * every user's kind 0, so this only watches the cache; it never asks a relay itself. + */ +@Composable +fun rememberHasBuzzAgentProfile(author: User): Boolean { + val note = remember(author) { LocalCache.getOrCreateAddressableNote(AgentProfileEvent.createAddress(author.pubkeyHex)) } + val hasProfile by produceState(note.event is AgentProfileEvent, note) { + note + .flow() + .metadata.stateFlow + .collect { value = note.event is AgentProfileEvent } + } + return hasProfile +} + +/** + * "Agent · managed by Alice" beside an agent's name, the way Buzz labels agents. [author] counts as + * an agent when it published a kind-10100 agent profile or its kind 0 names an [owner] through a + * verified NIP-OA `auth` tag; the owner part appears only for the latter. Draws nothing for a person. + */ +@Composable +fun BuzzAgentLabel( + author: User, + owner: HexKey?, + accountViewModel: AccountViewModel, + modifier: Modifier = Modifier, + showOwner: Boolean = true, + onOwnerClick: ((HexKey) -> Unit)? = null, +) { + val hasAgentProfile = rememberHasBuzzAgentProfile(author) + if (owner == null && !hasAgentProfile) return + + val agent = stringRes(Res.string.buzz_agent_label) + val text = + when { + owner == null || !showOwner -> agent + owner == accountViewModel.userProfile().pubkeyHex -> "$agent · ${stringRes(Res.string.buzz_agent_managed_by_you)}" + else -> "$agent · ${stringRes(Res.string.buzz_agent_managed_by, observeUserNameByHex(owner, accountViewModel))}" + } + + Text( + text = text, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = if (owner != null && showOwner && onOwnerClick != null) modifier.clickable { onOwnerClick(owner) } else modifier, + ) +} + +/** + * [author]'s contextual name in the Buzz [channel] ([BuzzIdentityNames]): the plain name unless + * another member shares it, then "Alice’s Honey", "Honey (agent)" or "Honey · 7xk2". Recomputed when + * profiles, agent profiles or the roster change, at most every quarter second. + */ +@OptIn(FlowPreview::class) +@Composable +fun rememberBuzzContextualName( + channel: RelayGroupChannel, + author: User, + accountViewModel: AccountViewModel, +): ResolvedIdentityName? { + val versions = remember { BuzzIdentityNames.version.debounce(250) } + val version by versions.collectAsStateWithLifecycle(BuzzIdentityNames.version.value) + val viewer = accountViewModel.userProfile().pubkeyHex + return remember(channel, author, viewer, version) { BuzzIdentityNames.labelFor(channel, author.pubkeyHex, viewer) } +} diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/NIP05VerificationDisplay.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/NIP05VerificationDisplay.kt index eb314f4e77..6f8cd9fad8 100644 --- a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/NIP05VerificationDisplay.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/NIP05VerificationDisplay.kt @@ -242,8 +242,22 @@ fun DisplayStatus( } } + // Buzz sets a plain emoji with a 2-element `["emoji", "🎉"]` tag instead of inlining it in the + // text; lead with it the way Buzz's own client does. NIP-30 custom emoji are handled above. + val text = + remember(event) { + val emoji = event.statusEmoji() + if (emoji == null) { + event.content + } else if (event.content.isBlank()) { + emoji + } else { + "$emoji ${event.content}" + } + } + DisplayStatusInner( - event.content, + text, event.dTag(), event.firstTaggedUrl()?.ifBlank { null }, event.firstTaggedAddress(), diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/buzz/AgentAttestationScreen.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/buzz/AgentAttestationScreen.kt index 451c3a7095..328f4855ef 100644 --- a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/buzz/AgentAttestationScreen.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/buzz/AgentAttestationScreen.kt @@ -76,6 +76,7 @@ import com.vitorpamplona.amethyst.commons.resources.buzz_attest_before_label import com.vitorpamplona.amethyst.commons.resources.buzz_attest_change_agent import com.vitorpamplona.amethyst.commons.resources.buzz_attest_conditions_hint import com.vitorpamplona.amethyst.commons.resources.buzz_attest_copy_tag +import com.vitorpamplona.amethyst.commons.resources.buzz_attest_expired import com.vitorpamplona.amethyst.commons.resources.buzz_attest_form_desc import com.vitorpamplona.amethyst.commons.resources.buzz_attest_generate import com.vitorpamplona.amethyst.commons.resources.buzz_attest_grants_prefix @@ -85,6 +86,7 @@ import com.vitorpamplona.amethyst.commons.resources.buzz_attest_hold_desc import com.vitorpamplona.amethyst.commons.resources.buzz_attest_hold_title import com.vitorpamplona.amethyst.commons.resources.buzz_attest_holding import com.vitorpamplona.amethyst.commons.resources.buzz_attest_kind_label +import com.vitorpamplona.amethyst.commons.resources.buzz_attest_not_yet_valid import com.vitorpamplona.amethyst.commons.resources.buzz_attest_readonly_desc import com.vitorpamplona.amethyst.commons.resources.buzz_attest_readonly_title import com.vitorpamplona.amethyst.commons.resources.buzz_attest_remove @@ -104,6 +106,7 @@ import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.isValid import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair import com.vitorpamplona.quartz.nip19Bech32.decodePublicKeyAsHexOrNull +import com.vitorpamplona.quartz.utils.TimeUtils import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.IO import kotlinx.coroutines.delay @@ -122,7 +125,7 @@ private val KIND_OPTIONS = DropdownOption("9", "9 · Group chat message"), DropdownOption("1111", "1111 · Comment"), DropdownOption("30023", "30023 · Long-form article"), - DropdownOption("40002", "40002 · Buzz minichat message"), + DropdownOption("40002", "40002 · Buzz stream message (legacy)"), ) private val KIND_LABELS = KIND_OPTIONS.associate { it.value to it.label } @@ -208,6 +211,17 @@ private fun HoldAttestationSection( style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) + // Buzz checks the time clauses against each AUTH, so a held credential outside them + // is silently worthless; say so rather than keep showing it as working. + val now = TimeUtils.now() + if (!mine.isValidAt(now)) { + val expired = mine.validUntil()?.let { it < now } == true + Text( + text = stringRes(if (expired) Res.string.buzz_attest_expired else Res.string.buzz_attest_not_yet_valid), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.error, + ) + } OutlinedButton(onClick = { attestation.clear() }) { Text(stringRes(Res.string.buzz_attest_remove)) } diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/buzz/AgentConsoleScreen.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/buzz/AgentConsoleScreen.kt index e2110fda0e..a20b9ae366 100644 --- a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/buzz/AgentConsoleScreen.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/buzz/AgentConsoleScreen.kt @@ -231,8 +231,10 @@ private fun TokenBreakdown(totals: TokenTotals) { MetaRow("Input tokens", formatCount(totals.inputTokens)) MetaRow("Output tokens", formatCount(totals.outputTokens)) MetaRow("Total tokens", formatCount(totals.totalTokens)) - if (totals.cacheReadTokens > 0) MetaRow("Cache read", formatCount(totals.cacheReadTokens)) - if (totals.cacheWriteTokens > 0) MetaRow("Cache write", formatCount(totals.cacheWriteTokens)) + // Null = no turn reported the component. NIP-AM forbids reading that as zero, so show a dash + // rather than hide the row (which would look the same as a reported 0). + MetaRow("Cache read", totals.cacheReadTokens?.let(::formatCount) ?: "—") + MetaRow("Cache write", totals.cacheWriteTokens?.let(::formatCount) ?: "—") } @Composable diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/buzz/AgentPersonaEditScreen.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/buzz/AgentPersonaEditScreen.kt index cb87858fa0..18a598f9c0 100644 --- a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/buzz/AgentPersonaEditScreen.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/buzz/AgentPersonaEditScreen.kt @@ -41,6 +41,8 @@ import com.vitorpamplona.amethyst.commons.buzz.ui.DropdownOption import com.vitorpamplona.amethyst.commons.buzz.ui.EditableSuggestDropdown import com.vitorpamplona.amethyst.commons.resources.Res import com.vitorpamplona.amethyst.commons.resources.buzz_persona_avatar +import com.vitorpamplona.amethyst.commons.resources.buzz_persona_description +import com.vitorpamplona.amethyst.commons.resources.buzz_persona_description_help import com.vitorpamplona.amethyst.commons.resources.buzz_persona_display_name import com.vitorpamplona.amethyst.commons.resources.buzz_persona_edit_title import com.vitorpamplona.amethyst.commons.resources.buzz_persona_model @@ -49,12 +51,17 @@ import com.vitorpamplona.amethyst.commons.resources.buzz_persona_provider import com.vitorpamplona.amethyst.commons.resources.buzz_persona_publish import com.vitorpamplona.amethyst.commons.resources.buzz_persona_publishing import com.vitorpamplona.amethyst.commons.resources.buzz_persona_runtime +import com.vitorpamplona.amethyst.commons.resources.buzz_persona_shared +import com.vitorpamplona.amethyst.commons.resources.buzz_persona_shared_help import com.vitorpamplona.amethyst.commons.resources.buzz_persona_slug import com.vitorpamplona.amethyst.commons.resources.buzz_persona_slug_help import com.vitorpamplona.amethyst.commons.resources.buzz_persona_system_prompt +import com.vitorpamplona.amethyst.commons.resources.buzz_persona_thread_sessions +import com.vitorpamplona.amethyst.commons.resources.buzz_persona_thread_sessions_help import com.vitorpamplona.amethyst.commons.ui.components.rememberViewModel import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav import com.vitorpamplona.amethyst.commons.ui.navigation.topbars.TopBarWithBackButton +import com.vitorpamplona.amethyst.commons.ui.note.creators.contentWarning.SettingSwitchItem import com.vitorpamplona.amethyst.commons.ui.stringRes import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel @@ -161,6 +168,28 @@ fun AgentPersonaEditScreen( singleLine = true, modifier = Modifier.fillMaxWidth(), ) + OutlinedTextField( + value = state.description, + onValueChange = viewModel::onDescriptionChange, + label = { Text(stringRes(Res.string.buzz_persona_description)) }, + supportingText = { Text(stringRes(Res.string.buzz_persona_description_help)) }, + minLines = 2, + modifier = Modifier.fillMaxWidth(), + ) + SettingSwitchItem( + modifier = Modifier.fillMaxWidth(), + checked = state.shared, + onCheckedChange = viewModel::onSharedChange, + title = Res.string.buzz_persona_shared, + description = Res.string.buzz_persona_shared_help, + ) + SettingSwitchItem( + modifier = Modifier.fillMaxWidth(), + checked = state.threadSessions, + onCheckedChange = viewModel::onThreadSessionsChange, + title = Res.string.buzz_persona_thread_sessions, + description = Res.string.buzz_persona_thread_sessions_help, + ) state.error?.let { Text( diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/buzz/AgentPersonaEditViewModel.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/buzz/AgentPersonaEditViewModel.kt index b8aeca7b13..2d6fe22c84 100644 --- a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/buzz/AgentPersonaEditViewModel.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/buzz/AgentPersonaEditViewModel.kt @@ -44,18 +44,20 @@ import kotlin.coroutines.cancellation.CancellationException * owner and addressed by `(owner, 30175, slug)`. * * On edit, the existing [PersonaEvent] is loaded from [LocalCache] so fields the form does - * not expose (avatar, name pool, respond-to allowlist, parallelism) are preserved rather - * than dropped on the next publish. The slug (the `d` tag) is immutable once chosen — a - * different slug is a different persona. + * not expose (ACP command, name pool, respond-to allowlist, parallelism) are preserved rather + * than dropped on the next publish, and the new head is stamped after the one it replaces. The + * slug (the `d` tag) is immutable once chosen — a different slug is a different persona. * * Published to the workspace's Buzz-dialect relays (falling back to the owner's outbox when - * none are known), so the workspace and other members see it. + * none are known). The relay serves it to other members only when [FormState.shared] is on + * (the `["shared","true"]` tag); otherwise only the owner's own devices can read it. */ class AgentPersonaEditViewModel : ViewModel() { @Volatile private var account: Account? = null /** Set on edit; preserves fields the form doesn't surface. Null for a new persona. */ private var existing: PersonaContent? = null + private var existingCreatedAt: Long? = null private var editingSlug: String? = null private val _state = MutableStateFlow(FormState()) @@ -74,6 +76,7 @@ class AgentPersonaEditViewModel : ViewModel() { val event = note?.event as? PersonaEvent ?: return val content = event.personaOrNull() ?: return existing = content + existingCreatedAt = event.createdAt editingSlug = slug _state.value = FormState( @@ -85,6 +88,9 @@ class AgentPersonaEditViewModel : ViewModel() { runtime = content.runtime.orEmpty(), provider = content.provider.orEmpty(), avatarUrl = content.avatarUrl.orEmpty(), + description = content.description.orEmpty(), + shared = event.isShared(), + threadSessions = content.isThreadSessionPolicy(), ) } @@ -102,6 +108,12 @@ class AgentPersonaEditViewModel : ViewModel() { fun onAvatarUrlChange(v: String) = _state.update { it.copy(avatarUrl = v.trim(), error = null) } + fun onDescriptionChange(v: String) = _state.update { it.copy(description = v.take(PersonaContent.DESCRIPTION_MAX_CHARS), error = null) } + + fun onSharedChange(v: Boolean) = _state.update { it.copy(shared = v, error = null) } + + fun onThreadSessionsChange(v: Boolean) = _state.update { it.copy(threadSessions = v, error = null) } + /** * Validates, builds a [PersonaEvent] (preserving unexposed fields on edit), signs and * publishes it to the Buzz relays. Calls [onDone] on the main thread on success. @@ -127,14 +139,19 @@ class AgentPersonaEditViewModel : ViewModel() { val content = base.copy( displayName = current.displayName.trim(), - systemPrompt = current.systemPrompt.blankToNull(), + // Buzz writes an empty prompt as "" (not absent) to keep the content hash + // stable, so a blank field keeps whichever form the persona already had. + systemPrompt = current.systemPrompt.blankToNull() ?: base.systemPrompt?.takeIf { it.isBlank() }, model = current.model.blankToNull(), runtime = current.runtime.blankToNull(), provider = current.provider.blankToNull(), avatarUrl = current.avatarUrl.blankToNull(), + description = current.description.blankToNull(), + // Absent is the default `channel` policy; only `thread` is written. + sessionPolicy = if (current.threadSessions) PersonaContent.SESSION_POLICY_THREAD else null, ) - val template = PersonaEvent.build(content, slug) + val template = PersonaEvent.build(content, slug, shared = current.shared, priorHeadCreatedAt = existingCreatedAt) account.signAndSendPrivatelyOrBroadcast(template) { BuzzRelayDialect.flow.value .toList() @@ -161,6 +178,12 @@ class AgentPersonaEditViewModel : ViewModel() { val runtime: String = "", val provider: String = "", val avatarUrl: String = "", + /** Short public description, at most [PersonaContent.DESCRIPTION_MAX_CHARS] characters. */ + val description: String = "", + /** Publish to the community catalog (`["shared","true"]`) instead of owner-only. */ + val shared: Boolean = false, + /** A separate ACP conversation per channel thread (`session_policy: "thread"`). */ + val threadSessions: Boolean = false, val isSaving: Boolean = false, val error: String? = null, ) { diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/buzz/BuzzCanvasScreen.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/buzz/BuzzCanvasScreen.kt index ce7eeeb538..36c2df5159 100644 --- a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/buzz/BuzzCanvasScreen.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/buzz/BuzzCanvasScreen.kt @@ -53,25 +53,32 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.commons.model.EmptyTagList +import com.vitorpamplona.amethyst.commons.model.buzz.BuzzCanvasWriter import com.vitorpamplona.amethyst.commons.model.buzz.BuzzWorkspaceStates import com.vitorpamplona.amethyst.commons.model.cache.LocalCache import com.vitorpamplona.amethyst.commons.resources.Res import com.vitorpamplona.amethyst.commons.resources.buzz_canvas_body_label +import com.vitorpamplona.amethyst.commons.resources.buzz_canvas_conflict import com.vitorpamplona.amethyst.commons.resources.buzz_canvas_edit import com.vitorpamplona.amethyst.commons.resources.buzz_canvas_empty +import com.vitorpamplona.amethyst.commons.resources.buzz_canvas_head_in_future import com.vitorpamplona.amethyst.commons.resources.buzz_canvas_save +import com.vitorpamplona.amethyst.commons.resources.buzz_canvas_save_failed import com.vitorpamplona.amethyst.commons.resources.buzz_canvas_title import com.vitorpamplona.amethyst.commons.resources.cancel import com.vitorpamplona.amethyst.commons.ui.components.PlatformBackHandler import com.vitorpamplona.amethyst.commons.ui.components.TranslatableRichTextViewer +import com.vitorpamplona.amethyst.commons.ui.loadStringRes import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav import com.vitorpamplona.amethyst.commons.ui.navigation.topbars.TopBarExtensibleWithBackButton import com.vitorpamplona.amethyst.commons.ui.stringRes import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel import com.vitorpamplona.quartz.buzz.stream.CanvasEvent import com.vitorpamplona.quartz.buzz.workspace.isBuzzDm +import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer import com.vitorpamplona.quartz.nip29RelayGroups.GroupId +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.IO import kotlinx.coroutines.launch @@ -87,8 +94,10 @@ import kotlinx.coroutines.withContext * re-composes off `canvasUpdates` when a newer revision lands. * * The edit FAB flips into a plain markdown editor; saving publishes a fresh [CanvasEvent] to the - * channel's host [relayUrl] (last-write-wins on the relay too), and the consume path folds the new - * revision back into [BuzzWorkspaceStates] so the view updates without a manual refresh. + * channel's host [relayUrl] through [BuzzCanvasWriter], which asserts the revision the editor was + * opened on (`expected-revision`) so a concurrent edit is refused by the relay instead of silently + * overwritten. An accepted revision is folded back into [BuzzWorkspaceStates] so the view updates + * without a manual refresh; a conflict keeps the editor open with the draft intact. */ @Composable fun BuzzCanvasScreen( @@ -119,15 +128,17 @@ fun BuzzCanvasScreen( // that Buzz's own client would never show. val canEdit = channel?.event?.isBuzzDm() != true - var editing by remember { mutableStateOf(false) } + // The head the editor was opened on, snapshotted at edit start (not the live head): a newer + // revision landing while the editor is open must surface as a conflict, not be overwritten. + var editBase by remember { mutableStateOf(null) } - if (editing) { + editBase?.let { base -> CanvasEditor( channelId = channelId, relayUrl = relayUrl, - initial = content.orEmpty(), + base = base, accountViewModel = accountViewModel, - onClose = { editing = false }, + onClose = { editBase = null }, ) return } @@ -160,7 +171,10 @@ fun BuzzCanvasScreen( }, floatingActionButton = { if (canEdit) { - FloatingActionButton(onClick = { editing = true }, shape = CircleShape) { + FloatingActionButton( + onClick = { editBase = CanvasEditBase(content.orEmpty(), canvas?.idHex, canvas?.createdAt()) }, + shape = CircleShape, + ) { Icon(symbol = MaterialSymbols.Edit, contentDescription = stringRes(Res.string.buzz_canvas_edit)) } } @@ -207,17 +221,24 @@ fun BuzzCanvasScreen( } } +/** The canvas revision an edit started from: its text, and its id/created_at (null when there was no canvas). */ +private class CanvasEditBase( + val content: String, + val headId: HexKey?, + val headCreatedAt: Long?, +) + /** The markdown editor: a full-height text field for the canvas body with a Save action. */ @OptIn(ExperimentalMaterial3Api::class) @Composable private fun CanvasEditor( channelId: String, relayUrl: String, - initial: String, + base: CanvasEditBase, accountViewModel: AccountViewModel, onClose: () -> Unit, ) { - var text by remember { mutableStateOf(initial) } + var text by remember { mutableStateOf(base.content) } var saving by remember { mutableStateOf(false) } var error by remember { mutableStateOf(null) } val scope = rememberCoroutineScope() @@ -249,15 +270,41 @@ private fun CanvasEditor( error = null scope.launch { try { - withContext(Dispatchers.IO) { - accountViewModel.account.signAndSendPrivatelyOrBroadcast( - CanvasEvent.build(channelId, text), - ) { listOf(relay) } + val outcome = + withContext(Dispatchers.IO) { + BuzzCanvasWriter.save( + account = accountViewModel.account, + relay = relay, + channelId = channelId, + markdown = text, + headId = base.headId, + headCreatedAt = base.headCreatedAt, + ) + } + when (outcome) { + is BuzzCanvasWriter.Outcome.Saved -> { + onClose() + } + + is BuzzCanvasWriter.Outcome.Conflict -> { + saving = false + error = loadStringRes(Res.string.buzz_canvas_conflict) + } + + BuzzCanvasWriter.Outcome.HeadTooFarInFuture -> { + saving = false + error = loadStringRes(Res.string.buzz_canvas_head_in_future) + } + + is BuzzCanvasWriter.Outcome.Failed -> { + saving = false + error = loadStringRes(Res.string.buzz_canvas_save_failed, outcome.message) + } } - onClose() } catch (e: Exception) { + if (e is CancellationException) throw e saving = false - error = "Failed to save: ${e.message ?: e::class.simpleName}" + error = loadStringRes(Res.string.buzz_canvas_save_failed, e.message ?: e::class.simpleName ?: "") } } }, diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/feed/types/RenderBuzzSystemMessage.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/feed/types/RenderBuzzSystemMessage.kt index 15407c8a23..38d4f404d0 100644 --- a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/feed/types/RenderBuzzSystemMessage.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/feed/types/RenderBuzzSystemMessage.kt @@ -32,7 +32,9 @@ import com.vitorpamplona.amethyst.commons.model.cache.LocalCache import com.vitorpamplona.amethyst.commons.model.navigation.Route import com.vitorpamplona.amethyst.commons.relayClient.user.observeUserName import com.vitorpamplona.amethyst.commons.resources.Res +import com.vitorpamplona.amethyst.commons.resources.buzz_system_admin_kick import com.vitorpamplona.amethyst.commons.resources.buzz_system_channel_archived +import com.vitorpamplona.amethyst.commons.resources.buzz_system_channel_auto_archived import com.vitorpamplona.amethyst.commons.resources.buzz_system_channel_created import com.vitorpamplona.amethyst.commons.resources.buzz_system_channel_deleted import com.vitorpamplona.amethyst.commons.resources.buzz_system_channel_unarchived @@ -179,6 +181,11 @@ fun buzzSystemMessageText( SystemMessagePayload.DM_CREATED -> stringRes(Res.string.buzz_system_dm_created, actor) + // Relay-authored with no actor: the administrator who acted is deliberately not named. + SystemMessagePayload.ADMIN_KICK -> stringRes(Res.string.buzz_system_admin_kick, target) + + SystemMessagePayload.CHANNEL_AUTO_ARCHIVED -> stringRes(Res.string.buzz_system_channel_auto_archived) + else -> stringRes(Res.string.buzz_system_unknown, actor, payload.type.replace('_', ' ')) } } diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupMembersScreen.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupMembersScreen.kt index 8fc523e52a..ddfd66bd87 100644 --- a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupMembersScreen.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupMembersScreen.kt @@ -218,7 +218,7 @@ private fun RelayGroupMembers( if (iCanModerate) { AddMemberBar( isAlreadyIn = { channel.membershipOf(it) != RelayGroupMembership.NONE }, - onAdd = { accountViewModel.putRelayGroupUser(channel, it, emptyList()) }, + onAdd = { accountViewModel.addRelayGroupUser(channel, it) }, accountViewModel = accountViewModel, ) } diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/AccountViewModel.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/AccountViewModel.kt index c0c776ec9b..b53610ad2a 100644 --- a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/AccountViewModel.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/AccountViewModel.kt @@ -1905,6 +1905,11 @@ class AccountViewModel( pubkey: HexKey, ) = launchSigner { account.relayGroups.removeRelayGroupUser(channel, pubkey) } + fun addRelayGroupUser( + channel: RelayGroupChannel, + pubkey: HexKey, + ) = launchSigner { account.relayGroups.addRelayGroupUser(channel, pubkey) } + fun putRelayGroupUser( channel: RelayGroupChannel, pubkey: HexKey, diff --git a/geode/src/main/kotlin/com/vitorpamplona/geode/config/RelayIdentity.kt b/geode/src/main/kotlin/com/vitorpamplona/geode/config/RelayIdentity.kt index 260bec6045..f7f6a278e1 100644 --- a/geode/src/main/kotlin/com/vitorpamplona/geode/config/RelayIdentity.kt +++ b/geode/src/main/kotlin/com/vitorpamplona/geode/config/RelayIdentity.kt @@ -66,7 +66,7 @@ object RelayIdentity { val key = KeyPair() file.absoluteFile.parentFile?.mkdirs() val tmp = File(file.absoluteFile.parentFile, "${file.name}.tmp") - tmp.delete() + Files.deleteIfExists(tmp.toPath()) try { Files.createFile(tmp.toPath(), PosixFilePermissions.asFileAttribute(PosixFilePermissions.fromString("rw-------"))) } catch (_: UnsupportedOperationException) { diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/README.md b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/README.md index e1d2491c59..504e08b0f5 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/README.md +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/README.md @@ -76,7 +76,7 @@ hand-transcribed schemas: Each feature is a sub-package following the standard Quartz per-NIP shape (`Event` with a `KIND` companion, a `tags/` folder of tag classes, `TagArrayBuilderExt` write-DSL verbs, and `TagArrayExt` read accessors), mirroring e.g. `nip88Polls`. There is one event -class per kind, ~78 in total, each registered in `utils/EventFactory.kt` (except the +class per kind, ~84 in total, each registered in `utils/EventFactory.kt` (except the conflicts below). | Package | Buzz NIP | Kind(s) | @@ -86,15 +86,61 @@ conflicts below). | `aoObserver` | NIP-AO | 24200 | | `aeEngrams` | NIP-AE | 30174 | | `apPersonas` / `teams` / `managedAgents` / `agentProfiles` | NIP-AP + agent identity | 30175 / 30176 / 30177 / 10100 | +| `teamCatalog` | NIP-AP team catalog (shared projection with embedded members) | 30178 | | `erReminders` / `plPushLease` / `dvDmVisibility` / `wpWorkspaceProfile` | NIP-ER/PL/DV/WP | 30300 / 30350 / 30622 / 9033 | -| `iaIdentityArchival` / `cwChannelWindow` | NIP-IA / NIP-CW | 9035/9036/8002/8003/13535 / 39005/39006 | +| `iaIdentityArchival` / `cwChannelWindow` | NIP-IA / NIP-CW | 9035/9036/8002/8003/13535 / 39005/39006/39007 | | `relayAdmin` / `moderation` | admin + moderation | 9030-9032 / 9040-9044, 42000 | | `stream` / `stream.sidecars` | stream messaging | 40002-40008, 40099, 40100 / 40901, 40902 | | `dm` / `jobs` | DMs / agent jobs | 41001, 41010-41012 / 43001-43006 | | `workflow` / `forum` / `notifications` | workflow + social | 30620, 46001-46031 / 45001-45003 / 44100, 44101 | -| `presence` / `huddles` / `pairing` / `audit` / `media` | presence + misc | 20001, 20002 / 24810, 48100-48106 / 24134 / 48001 / 49001 | +| `arArtifacts` | NIP-AR channel artifacts (revision chain by `prev`) | 45010, 45011 (relay-signed removal) | +| `mpProjects` | NIP-MP multi-repo projects (global, not `h`-scoped) | 30621 | +| `presence` / `huddles` / `pairing` / `audit` / `media` | presence + misc | 20001, 20002 / 24810, 48100-48106 (48104 = relay-synthesized liveness) / 24134 / 48001 / 49001 | | `rsReadState` | NIP-RS | (helpers on `AppSpecificDataEvent`, kind 30078) | +Not modelled on purpose: **NIP-PMA `kind:30179`** (private managed agent) is only reserved upstream +— its spec says relays MUST reject it for now — and **NIP-FI** federated identity is an HTTP-upgrade +header, not an event. + +### Upstream sync + +Last reconciled against `block/buzz` `4ef23609b` (2026-09-29); the previous full sync was +`03fe19d6` (2026-07-21). Behaviour picked up in that pass, beyond the new kinds above: + +- **Read gates.** Personas (30175) and the team catalog (30178) are author-only unless the event + carries exactly `["shared","true"]` (`apPersonas/tags/SharedTag`); a malformed `shared` tag is + rejected at ingest. Persona content gained `acp_command`, `description` and `session_policy` + (upstream field order kept, so content bytes and hashes match), and a shared head publishes only a + portable harness alias (`PersonaContent.forSharedCatalog`). +- **NIP-OA time bounds** are enforced at admission against the AUTH event's `created_at` + (strictly, every clause, `kind=` ignored): `OwnerAttestation.verifyForAuthAt`. Owner keys and + signatures must be lowercase hex, and the `auth` tag exactly four elements. +- **NIP-10 threading** follows `buzz-core/src/nip10.rs`: markers need 4+ elements and a 64-hex id, + the last one wins, and a lone `root` marker is top-level (`threading/BuzzThreadMarkers`). A reply's + root must be derived with `buzzThreadRootForReplyTo` or the relay rejects it. +- **Channel messages are written as kind 9** in Buzz's tag shape (`stream/BuzzChatMessage`, mirroring + `build_message`): `h`, NIP-10 thread markers, `p` mentions, `broadcast` for a reply that also shows + in the channel. That is what Buzz's own clients write; kind 40002 is read-only now, kept so older + messages still render. `broadcast` means the same on either kind. +- **Edits (40003)** apply to any channel message kind. The relay stores them as-is; clients resolve + them: the newest edit by the message's author, or by the owner that author declares through a + verified NIP-OA `auth` tag on its kind-0 profile (`OwnerAttestation.verifiedOwnerOf`), wins. A + relay-signed message counts as written by its `actor` (else `p`). The edit's tags overlay the + original's (`stream/BuzzEditTagOverlay`): attachments only from the edit, custom emoji from the + edit when it has any, added `p` mentions merged. So an edit re-sends every attachment and emoji + the new text still uses. +- **Contextual identity names** (`identityNames/IdentityNamePolicy`, Buzz's portable v1 contract, + run against its vendored fixtures in `IdentityNamePolicyTest`): within one context (a channel's + members) a name only grows on a real collision: a person keeps it, an agent becomes `Alice’s Honey` + or `Honey (agent)`, and anyone still colliding gets an npub suffix. `commons/.../BuzzIdentityNames` + applies it per relay group; agents are authors with a 10100 profile or a verified NIP-OA owner. +- **Put-user (9000)** without a `role` tag is "no role change" on Buzz; a plain add sends none. +- **Compare-and-swap writes**: canvas (40100) and workflow definitions (30620) take + `["expected-revision", ]` and the relay answers `conflict:` on a stale head. A workflow's + `d` must be a UUID. +- A REQ that hits the relay's query deadline ends with `CLOSED "error: query timed out"`, which the + pool treats as transient rather than a structural refusal. + ### Kind conflicts — where a Buzz kind number is already owned Several Buzz kind numbers collide with an existing Amethyst/Nostr class. `EventFactory` @@ -221,7 +267,7 @@ a Buzz relay once a canvas has arrived. The **edit composer (40003)** is wired: `ChannelNewMessageViewModel` has a Buzz edit mode (`editBuzzMessage`/`clearBuzzEdit`) whose next send publishes a 40003 targeting the original (minimal, mirroring Buzz's `build_edit`); an "Edit" action gated to the user's -own 40002 messages threads to the composer through the shared chat feed via an optional +own messages (kind 9 on a Buzz relay, or legacy 40002) threads to the composer through the shared chat feed via an optional `onWantsToEditBuzz` callback (default-null, so no other chat surface is affected), and `EditFieldRow` shows an editing banner. This closes the render↔create loop (edit overlays already rendered). diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/agentProfiles/AgentProfileEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/agentProfiles/AgentProfileEvent.kt index 2351d4a9f9..67048c4d6b 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/agentProfiles/AgentProfileEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/agentProfiles/AgentProfileEvent.kt @@ -21,6 +21,7 @@ package com.vitorpamplona.quartz.buzz.agentProfiles import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.core.Address import com.vitorpamplona.quartz.nip01Core.core.BaseReplaceableEvent import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder @@ -75,6 +76,9 @@ class AgentProfileEvent( companion object { const val KIND = 10100 + /** The replaceable address of [pubKey]'s agent profile (`10100::`). */ + fun createAddress(pubKey: HexKey): Address = Address(KIND, pubKey, "") + fun build( profile: AgentProfileContent, createdAt: Long = TimeUtils.now(), diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/amTurnMetrics/AgentTurnMetricPayload.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/amTurnMetrics/AgentTurnMetricPayload.kt index 63783ca576..532eb36317 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/amTurnMetrics/AgentTurnMetricPayload.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/amTurnMetrics/AgentTurnMetricPayload.kt @@ -36,6 +36,11 @@ import kotlinx.serialization.json.Json * on purpose — `null` means "the harness did not report this", not zero. Unknown JSON * fields are ignored for forward compatibility, and an unrecognized [stopReason] is * kept verbatim (map it with [stopReasonOrUnknown]) rather than dropping the payload. + * + * [pricingIdentity] is optional but not nullable on the wire: it is present only when the + * publisher could prove the billing authority and the actually-requested model, and its absence + * means "price unknown" — never infer a price from [model], which stays the configured/session + * model. */ @Serializable data class AgentTurnMetricPayload( @@ -50,6 +55,7 @@ data class AgentTurnMetricPayload( val cumulative: TokenCounts? = null, val deltaReliable: Boolean = true, val stopReason: String? = null, + val pricingIdentity: PricingIdentity? = null, ) { /** Maps [stopReason] to a [StopReason], treating any unrecognized value as [StopReason.UNKNOWN]. */ fun stopReasonOrUnknown(): StopReason? = stopReason?.let { StopReason.fromWire(it) } @@ -79,9 +85,44 @@ data class AgentTurnMetricPayload( } } +/** + * The billing identity of a turn (NIP-AM `pricingIdentity`), mirroring `PricingIdentity` in + * `agent_turn_metric.rs`. When present, [authority] and [model] are required strings; [cacheClass] + * is omitted (never null) when not applicable. + * + * Pricing lookup is an exact string match on `(authority, model)`. The Rust parser does not + * restrict [authority] to the registered set, so neither does this one — check + * [isRegisteredAuthority] before trusting it for a price. + */ +@Serializable +data class PricingIdentity( + /** The billing namespace: an exact lowercase hostname such as `api.anthropic.com` (not the transport provider). */ + val authority: String, + /** The actually-requested billable model id, as resolved at request time. */ + val model: String, + /** The cache-write class (e.g. `ephemeral`), when applicable. */ + val cacheClass: String? = null, +) { + /** True when [authority] is one of the NIP's registered billing namespaces. */ + fun isRegisteredAuthority(): Boolean = authority in REGISTERED_AUTHORITIES + + companion object { + const val AUTHORITY_ANTHROPIC = "api.anthropic.com" + const val AUTHORITY_OPENAI = "api.openai.com" + const val AUTHORITY_OPENROUTER = "openrouter.ai" + + /** The registered `authority` values; the set only grows by amendment to NIP-AM. */ + val REGISTERED_AUTHORITIES = setOf(AUTHORITY_ANTHROPIC, AUTHORITY_OPENAI, AUTHORITY_OPENROUTER) + } +} + /** * Token usage counts. All fields nullable — `null` distinguishes "not reported" from * zero. Mirrors `TokenCounts` in `agent_turn_metric.rs`. + * + * The cache components ([cacheReadTokens], [cacheWriteTokens]) are informational subsets of + * [inputTokens]. NIP-AM has publishers keep an explicit zero and *omit* a component they cannot + * see, so an absent cache field is "unknown" — never sum it as zero. */ @Serializable data class TokenCounts( diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/apPersonas/PersonaContent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/apPersonas/PersonaContent.kt index ae3b243f87..750ddb11f5 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/apPersonas/PersonaContent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/apPersonas/PersonaContent.kt @@ -47,6 +47,9 @@ import kotlinx.serialization.json.Json data class PersonaContent( @SerialName("display_name") val displayName: String, @SerialName("system_prompt") val systemPrompt: String? = null, + // The ACP harness command the agent runs under. On a shared (catalog) head it carries only a + // portable alias - see [forSharedCatalog]. + @SerialName("acp_command") val acpCommand: String? = null, @SerialName("avatar_url") val avatarUrl: String? = null, val runtime: String? = null, val model: String? = null, @@ -58,9 +61,34 @@ data class PersonaContent( @SerialName("respond_to") val respondTo: String? = null, @EncodeDefault(EncodeDefault.Mode.NEVER) @SerialName("respond_to_allowlist") val respondToAllowlist: List = emptyList(), val parallelism: Int? = null, + // Appended after the original fields upstream so a record without them serializes + // byte-identically to the pre-revision era. [description] is short PUBLIC display text + // (max [DESCRIPTION_MAX_CHARS]); [sessionPolicy] is omitted for the default `channel`. + val description: String? = null, + @SerialName("session_policy") val sessionPolicy: String? = null, ) { fun encodeToJson(): String = JSON.encodeToString(this) + /** True when the agent keeps a separate ACP conversation per channel thread. */ + fun isThreadSessionPolicy() = sessionPolicy == SESSION_POLICY_THREAD + + /** + * The projection Buzz publishes on a shared (`["shared","true"]`) head: a catalog reader on + * another machine can only run a portable harness alias, so an unset command becomes the + * explicit stock [DEFAULT_ACP_COMMAND] (distinguishing a reset from an omitted override) and + * a machine-local command is dropped. Mirrors `build_persona_event` in Buzz's + * `desktop/src-tauri/src/managed_agents/persona_events.rs`. + */ + fun forSharedCatalog(): PersonaContent = + copy( + acpCommand = + when { + acpCommand == null -> DEFAULT_ACP_COMMAND + isPortableAcpCommand(acpCommand) -> acpCommand + else -> null + }, + ) + companion object { val JSON = Json { @@ -70,5 +98,27 @@ data class PersonaContent( } fun decodeFromJson(json: String): PersonaContent = JSON.decodeFromString(json) + + /** Buzz's stock ACP harness (`DEFAULT_ACP_COMMAND` in `managed_agents/types.rs`). */ + const val DEFAULT_ACP_COMMAND = "buzz-acp" + + /** The only non-default [sessionPolicy] value; anything else reads as `channel`. */ + const val SESSION_POLICY_THREAD = "thread" + + /** Upper bound Buzz puts on [description]. */ + const val DESCRIPTION_MAX_CHARS = 280 + + /** + * A harness command another machine can run: the stock `buzz-acp`, or `buzz--acp` + * with `` of ASCII letters, digits, `-` or `_`, at most 255 bytes in all. Mirrors + * `is_portable_acp_command` in Buzz's `managed_agents/backend.rs`. + */ + fun isPortableAcpCommand(command: String): Boolean { + if (command == DEFAULT_ACP_COMMAND) return true + if (command.length > 255 || !command.startsWith("buzz-") || !command.endsWith("-acp")) return false + if (command.length <= "buzz-".length + "-acp".length) return false + val name = command.substring("buzz-".length, command.length - "-acp".length) + return name.all { it in 'a'..'z' || it in 'A'..'Z' || it in '0'..'9' || it == '-' || it == '_' } + } } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/apPersonas/PersonaEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/apPersonas/PersonaEvent.kt index 5ca7506c8e..f0af957616 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/apPersonas/PersonaEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/apPersonas/PersonaEvent.kt @@ -21,6 +21,7 @@ package com.vitorpamplona.quartz.buzz.apPersonas import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.buzz.apPersonas.tags.SharedTag import com.vitorpamplona.quartz.nip01Core.core.BaseAddressableEvent import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder @@ -39,6 +40,11 @@ import kotlinx.coroutines.CancellationException * * The `content` is a plaintext JSON [PersonaContent]. Ground truth for the content projection * is `desktop/src-tauri/src/managed_agents/persona_events.rs`. + * + * Read access is **author-only unless shared**: the relay serves a persona to anyone but its + * author only when it carries exactly `["shared","true"]` ([SharedTag]); otherwise it is + * silently withheld from foreign REQs, COUNTs and id lookups. Device sync reads + * `authors:[self]`, so the owner always sees their own. */ @Immutable class PersonaEvent( @@ -63,6 +69,9 @@ class PersonaEvent( /** The persona slug — the `d` tag. */ fun slug() = dTag() + /** True when the persona is published to the community catalog (`["shared","true"]`). */ + fun isShared() = SharedTag.isShared(tags) + /** Parses the persona configuration, or throws if the JSON is malformed. */ fun persona(): PersonaContent = PersonaContent.decodeFromJson(content) @@ -77,14 +86,31 @@ class PersonaEvent( companion object { const val KIND = 30175 + /** + * Builds a persona the way Buzz's `build_persona_event` does: a `d` slug tag, plus + * `["shared","true"]` when [shared]. A shared head publishes the portable catalog + * projection of its content ([PersonaContent.forSharedCatalog]). + * + * An edit should pass [priorHeadCreatedAt] (the replaced head's `created_at`) so the new + * head sorts after it even when this clock lags (`monotonic_created_at` upstream). + */ fun build( persona: PersonaContent, slug: String, - createdAt: Long = TimeUtils.now(), + shared: Boolean = false, + priorHeadCreatedAt: Long? = null, + createdAt: Long = monotonicCreatedAt(priorHeadCreatedAt), initializer: TagArrayBuilder.() -> Unit = {}, - ) = eventTemplate(KIND, persona.encodeToJson(), createdAt) { + ) = eventTemplate(KIND, (if (shared) persona.forSharedCatalog() else persona).encodeToJson(), createdAt) { dTag(slug) + if (shared) addUnique(SharedTag.assemble()) initializer() } + + /** `max(now, prior + 1)`: a replacement head never sorts behind the one it replaces. */ + fun monotonicCreatedAt(priorHeadCreatedAt: Long?): Long { + val now = TimeUtils.now() + return if (priorHeadCreatedAt == null) now else maxOf(now, priorHeadCreatedAt + 1) + } } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/apPersonas/tags/SharedTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/apPersonas/tags/SharedTag.kt new file mode 100644 index 0000000000..a430548262 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/apPersonas/tags/SharedTag.kt @@ -0,0 +1,55 @@ +/* + * 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.buzz.apPersonas.tags + +import com.vitorpamplona.quartz.nip01Core.core.TagArray + +/** + * The catalog opt-in on Buzz's shared-gated kinds (the persona `kind:30175` and the team + * catalog `kind:30178`): exactly `["shared","true"]`. Without it the relay serves the event + * to its author only; with it, to the whole community. It is a tag rather than a content field + * so toggling it leaves the content bytes (and the persona content hash) unchanged. + * + * The relay refuses any other `shared` shape at ingest (a different value, a third element, or + * a second `shared` tag), and its read gate fails closed on them. Ground truth: + * `event_is_shared` in Buzz's `buzz-core/src/kind.rs` and `validate_shared_tag` in + * `buzz-relay/src/handlers/ingest.rs`. + */ +object SharedTag { + const val TAG_NAME = "shared" + const val VALUE = "true" + + fun assemble() = arrayOf(TAG_NAME, VALUE) + + /** + * True only when [tags] carry exactly one `shared` tag and it is exactly `["shared","true"]`; + * any malformed or duplicated `shared` tag reads as not shared, like the relay's read gate. + */ + fun isShared(tags: TagArray): Boolean { + var count = 0 + for (tag in tags) { + if (tag.isEmpty() || tag[0] != TAG_NAME) continue + if (tag.size != 2 || tag[1] != VALUE) return false + count++ + } + return count == 1 + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/arArtifacts/ArtifactEnvelope.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/arArtifacts/ArtifactEnvelope.kt new file mode 100644 index 0000000000..b09a5e5e4e --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/arArtifacts/ArtifactEnvelope.kt @@ -0,0 +1,177 @@ +/* + * 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.buzz.arArtifacts + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.buzz.arArtifacts.tags.ArtifactOp +import com.vitorpamplona.quartz.buzz.arArtifacts.tags.OpTag +import com.vitorpamplona.quartz.buzz.arArtifacts.tags.PrevTag +import com.vitorpamplona.quartz.buzz.arArtifacts.tags.RootTag +import com.vitorpamplona.quartz.buzz.arArtifacts.tags.TitleTag +import com.vitorpamplona.quartz.buzz.arArtifacts.tags.TypeTag +import com.vitorpamplona.quartz.buzz.arArtifacts.tags.VersionTag +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.TagArray +import com.vitorpamplona.quartz.nip01Core.core.fastAny +import com.vitorpamplona.quartz.nip01Core.tags.dTag.DTag +import com.vitorpamplona.quartz.nip29RelayGroups.tags.GroupIdTag + +/** + * The authoritative NIP-AR envelope of a revision, independent of any client's content + * schema. Mirrors `ArtifactEnvelope` in Buzz's `buzz-core/src/artifact.rs`. + */ +@Immutable +data class ArtifactEnvelope( + /** Community-local stable identity — the `d` UUID. */ + val id: String, + /** Home channel — the `h` UUID. */ + val home: String, + /** Immutable namespaced type name. */ + val type: String, + val op: ArtifactOp, + /** The revision this one replaces; null exactly on `create`. */ + val prev: HexKey?, + /** Optional conversation anchor. */ + val root: HexKey?, +) + +/** The outcome of [ArtifactValidator.validate]. */ +sealed interface ArtifactValidation { + data class Valid( + val envelope: ArtifactEnvelope, + ) : ArtifactValidation + + /** [reason] is upstream's rejection message, verbatim. */ + data class Invalid( + val reason: String, + ) : ArtifactValidation +} + +/** + * NIP-AR envelope validation — a line-for-line port of `validate` in Buzz's + * `buzz-core/src/artifact.rs`, which the relay runs on every `kind:45010` before + * authorization. Payloads (content and client tags) stay opaque; NIP-OA `auth` tag + * verification is left to the relay, as upstream does. + */ +object ArtifactValidator { + /** Maximum artifact tags, including the envelope. */ + const val MAX_TAGS = 256 + + /** Maximum UTF-8 bytes in a tag name. */ + const val MAX_TAG_NAME_BYTES = 128 + + /** Maximum UTF-8 bytes in each tag element. */ + const val MAX_TAG_VALUE_BYTES = 4096 + + /** Maximum UTF-8 bytes across all tag elements. */ + const val MAX_TAG_BYTES = 65536 + + /** The NIP-OA attestation tag, the only non-envelope tag a delete may carry. */ + const val AUTH_TAG_NAME = "auth" + + /** Envelope tag names: each at most once, with exactly two elements. */ + val ENVELOPE_TAG_NAMES = + setOf( + VersionTag.TAG_NAME, + DTag.TAG_NAME, + GroupIdTag.TAG_NAME, + TypeTag.TAG_NAME, + TitleTag.TAG_NAME, + OpTag.TAG_NAME, + RootTag.TAG_NAME, + PrevTag.TAG_NAME, + ) + + fun validate( + tags: TagArray, + content: String, + ): ArtifactValidation { + if (tags.size > MAX_TAGS) return invalid("too many tags") + + val fields = HashMap() + var bytes = 0 + for (tag in tags) { + if (tag.isEmpty()) return invalid("empty tag") + val name = tag[0] + if (name.utf8Size() > MAX_TAG_NAME_BYTES) return invalid("tag name too long") + for (value in tag) { + val size = value.utf8Size() + bytes += size + if (size > MAX_TAG_VALUE_BYTES) return invalid("tag value too long") + } + if (name in ENVELOPE_TAG_NAMES && (tag.size != 2 || fields.put(name, tag[1]) != null)) { + return invalid("envelope tags must occur once with exactly two elements") + } + } + if (bytes > MAX_TAG_BYTES) return invalid("total tag bytes exceeded") + + val version = fields[VersionTag.TAG_NAME] ?: return missing() + if (version != VersionTag.CURRENT) return invalid("unsupported artifact envelope version") + + val id = fields[DTag.TAG_NAME] ?: return missing() + if (!ArtifactIds.isCanonicalUuid(id)) return invalidUuid(id) + val home = fields[GroupIdTag.TAG_NAME] ?: return missing() + if (!ArtifactIds.isCanonicalUuid(home)) return invalidUuid(home) + + val type = fields[TypeTag.TAG_NAME] ?: return missing() + if (!TypeTag.isValid(type)) return invalid("invalid namespaced artifact type") + + val opCode = fields[OpTag.TAG_NAME] ?: return missing() + val op = ArtifactOp.parse(opCode) ?: return invalid("invalid artifact operation") + + val prev = fields[PrevTag.TAG_NAME] + if (prev != null && !ArtifactIds.isEventId(prev)) return invalid("event ID must be 64 lowercase hex characters") + if ((op == ArtifactOp.CREATE) != (prev == null)) return invalid("prev required exactly on non-create revisions") + + val root = fields[RootTag.TAG_NAME] + if (root != null && !ArtifactIds.isEventId(root)) return invalid("event ID must be 64 lowercase hex characters") + + if (op == ArtifactOp.DELETE) { + if (TitleTag.TAG_NAME in fields || content.isNotEmpty()) { + return invalid("delete must omit title and have empty content") + } + if (tags.fastAny { it[0] !in ENVELOPE_TAG_NAMES && it[0] != AUTH_TAG_NAME }) { + return invalid("delete allows only envelope and verified auth tags") + } + } else { + val title = fields[TitleTag.TAG_NAME] ?: return missing() + if (!TitleTag.isValid(title)) return invalid("title must be nonblank and at most 512 UTF-8 bytes") + } + + return ArtifactValidation.Valid(ArtifactEnvelope(id, home, type, op, prev, root)) + } + + private fun String.utf8Size() = encodeToByteArray().size + + private fun invalid(reason: String) = ArtifactValidation.Invalid(reason) + + private fun missing() = invalid("missing required envelope tag") + + // `canonical_uuid` distinguishes an unparseable value from a non-canonical one. + private fun invalidUuid(value: String) = + if (UUID_ANY_FORM.matches(value)) { + invalid("UUID must be canonical lowercase and non-nil") + } else { + invalid("invalid UUID") + } + + private val UUID_ANY_FORM = Regex("^(urn:uuid:)?\\{?[0-9a-fA-F]{8}-?[0-9a-fA-F]{4}-?[0-9a-fA-F]{4}-?[0-9a-fA-F]{4}-?[0-9a-fA-F]{12}}?$") +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/arArtifacts/ArtifactEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/arArtifacts/ArtifactEvent.kt new file mode 100644 index 0000000000..daefc1e1a8 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/arArtifacts/ArtifactEvent.kt @@ -0,0 +1,201 @@ +/* + * 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.buzz.arArtifacts + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.buzz.arArtifacts.tags.ArtifactOp +import com.vitorpamplona.quartz.buzz.arArtifacts.tags.TitleTag +import com.vitorpamplona.quartz.buzz.arArtifacts.tags.TypeTag +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate +import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate +import com.vitorpamplona.quartz.utils.TimeUtils + +/** + * A Buzz NIP-AR artifact revision (`kind:45010`): one complete snapshot of an editable, + * channel-homed record (a task, a project card, …). User-signed and **regular**, not + * addressable — identity is `(community, d)` and the relay advances a per-identity head + * atomically, so every non-create revision must name the current head in `prev` + * (compare-and-swap; timestamps never pick a winner, see [ArtifactHeadResolver]). + * + * Envelope tags, each exactly once with two elements: `ar`=`1`, `d` (lowercase UUID), `h` + * (home channel UUID), `type` (namespaced, immutable), `title` (absent on delete), `op`, + * optional `root` (conversation anchor), and `prev` (required iff `op != create`). Any other + * tag is a client-defined, relay-matchable annotation ([clientTags]); `content` is opaque + * and empty on delete. At most 256 tags. + * + * Ground truth: `validate` in Buzz's `buzz-core/src/artifact.rs` (mirrored by + * [ArtifactValidator]), `accept_artifact` in `buzz-db/src/store/artifact.rs`, and + * `docs/nips/NIP-AR.md`. Buzz ships no SDK builder for this kind yet; [build] enforces the + * same envelope the relay validates. + * + * Reads: a plain REQ `{"kinds":[45010,45011],"#h":[channel]}` replays a channel's revisions + * and removals. Current-state and history views (and any multi-letter tag predicate such as + * `#project`) go through the relay's explicit artifact query (`{"artifact":"current"|"history", + * …}` on the NIP-98 HTTP `/query`); the relay rejects a REQ that mixes artifact kinds with + * multi-letter tag filters rather than silently dropping the predicate. + */ +@Immutable +class ArtifactEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : Event(id, pubKey, createdAt, KIND, tags, content, sig) { + /** The artifact's stable UUID — the `d` tag. */ + fun artifactId() = tags.artifactId() + + /** The home channel UUID — the `h` tag. */ + fun home() = tags.artifactHome() + + /** The namespaced content type — the `type` tag. */ + fun type() = tags.artifactType() + + /** The display title — the `title` tag; null on a delete. */ + fun title() = tags.artifactTitle() + + /** The lifecycle operation — the `op` tag. */ + fun op() = tags.artifactOp() + + /** The conversation anchor — the `root` tag. */ + fun root() = tags.artifactRoot() + + /** The replaced revision — the `prev` tag; null on a create. */ + fun prev() = tags.artifactPrev() + + /** True for a soft-delete revision. */ + fun isDelete() = op() == ArtifactOp.DELETE + + /** The client-defined annotation tags an editor must carry forward. */ + fun clientTags() = tags.artifactClientTags() + + /** Runs the relay's envelope validation over this revision. */ + fun validate(): ArtifactValidation = ArtifactValidator.validate(tags, content) + + /** The validated envelope, or null when the relay would reject this revision. */ + fun envelopeOrNull(): ArtifactEnvelope? = (validate() as? ArtifactValidation.Valid)?.envelope + + fun isWellFormed() = validate() is ArtifactValidation.Valid + + companion object { + const val KIND = 45010 + + /** + * Builds a revision and checks it against [ArtifactValidator] — including any client + * tags the [initializer] adds — so a template that the relay would reject never + * leaves this function. + * + * [title] must be null exactly when [op] is [ArtifactOp.DELETE] (and [content] then + * empty); [prev] must be null exactly when [op] is [ArtifactOp.CREATE]. + */ + fun build( + artifactId: String, + channelId: String, + type: String, + op: ArtifactOp, + title: String?, + content: String, + prev: HexKey? = null, + root: HexKey? = null, + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ): EventTemplate { + require(ArtifactIds.isCanonicalUuid(artifactId)) { "artifact id must be a lowercase, non-nil UUID" } + require(ArtifactIds.isCanonicalUuid(channelId)) { "channel id must be a lowercase, non-nil UUID" } + require(TypeTag.isValid(type)) { "invalid namespaced artifact type: $type" } + if (op == ArtifactOp.DELETE) { + require(title == null) { "a delete must omit the title" } + require(content.isEmpty()) { "a delete must have empty content" } + } else { + require(title != null && TitleTag.isValid(title)) { "title must be nonblank and at most ${TitleTag.MAX_BYTES} UTF-8 bytes" } + } + require((op == ArtifactOp.CREATE) == (prev == null)) { "prev is required exactly on non-create revisions" } + require(prev == null || ArtifactIds.isEventId(prev)) { "prev must be a 64-char lowercase hex event id" } + require(root == null || ArtifactIds.isEventId(root)) { "root must be a 64-char lowercase hex event id" } + + val template = + eventTemplate(KIND, content, createdAt) { + artifactVersion() + artifactId(artifactId) + artifactHome(channelId) + artifactType(type) + title?.let { artifactTitle(it) } + artifactOp(op) + root?.let { artifactRoot(it) } + prev?.let { artifactPrev(it) } + initializer() + } + + val result = ArtifactValidator.validate(template.tags, template.content) + require(result is ArtifactValidation.Valid) { "invalid artifact envelope: ${(result as ArtifactValidation.Invalid).reason}" } + return template + } + + /** Creates a new artifact identity. Pass client tags (e.g. `assignee`) via [initializer]. */ + fun create( + artifactId: String, + channelId: String, + type: String, + title: String, + content: String, + root: HexKey? = null, + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = build(artifactId, channelId, type, ArtifactOp.CREATE, title, content, null, root, createdAt, initializer) + + /** + * An `update` on top of [current] (which must be the head the relay holds): keeps its + * `d`, `h`, `type` and `root`, names it in `prev`, and carries its client tags forward + * unless [keepClientTags] is false — NIP-AR editors must preserve annotations they do + * not understand. The [initializer] runs after the carry-over, so it can replace them. + */ + fun update( + current: ArtifactEvent, + title: String, + content: String, + keepClientTags: Boolean = true, + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ): EventTemplate { + val envelope = requireNotNull(current.envelopeOrNull()) { "current revision has an invalid envelope" } + return build(envelope.id, envelope.home, envelope.type, ArtifactOp.UPDATE, title, content, current.id, envelope.root, createdAt) { + if (keepClientTags) current.clientTags().forEach { add(it) } + initializer() + } + } + + /** + * The soft delete of [current]: same `d`, `h`, `type` and `root` (the relay rejects a + * delete that changes the anchor), empty content, no title and no client tags. + */ + fun delete( + current: ArtifactEvent, + createdAt: Long = TimeUtils.now(), + ): EventTemplate { + val envelope = requireNotNull(current.envelopeOrNull()) { "current revision has an invalid envelope" } + return build(envelope.id, envelope.home, envelope.type, ArtifactOp.DELETE, null, "", current.id, envelope.root, createdAt) + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/arArtifacts/ArtifactHeadResolver.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/arArtifacts/ArtifactHeadResolver.kt new file mode 100644 index 0000000000..b2609bc317 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/arArtifacts/ArtifactHeadResolver.kt @@ -0,0 +1,162 @@ +/* + * 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.buzz.arArtifacts + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.buzz.arArtifacts.tags.ArtifactOp +import com.vitorpamplona.quartz.nip01Core.core.HexKey + +/** What a set of revisions says about one artifact's current state. See [ArtifactHeadResolver]. */ +@Immutable +sealed interface ArtifactHead { + /** No well-formed revision of the artifact was supplied. */ + data object Unknown : ArtifactHead + + /** + * [revision] is the current head. [chainComplete] is true when its `prev` chain reaches the + * `create` with no missing revision; false when the head was found across a gap (a redacted, + * expired or unreadable revision), which is still unambiguous for a linear history. + */ + data class Current( + val revision: ArtifactEvent, + val chainComplete: Boolean, + ) : ArtifactHead { + /** A soft-deleted artifact: hidden from active views until a `restore`. */ + val isDeleted get() = revision.op() == ArtifactOp.DELETE + } + + /** + * The newest visible revision, [lastRevision], was replaced by a move into a channel this + * reader cannot see — the relay-signed [removal] names it as `prev`. + */ + data class MovedAway( + val lastRevision: ArtifactEvent, + val removal: ArtifactRemovalEvent, + ) : ArtifactHead + + /** + * The revisions do not determine a single head: a fork (impossible on a Buzz relay, which + * serializes every identity, so it signals revisions from elsewhere), several gaps, or a + * `prev` cycle ([candidates] is then empty). Ask the relay's current-state query + * (`{"artifact":"current","#d":[id]}`) instead of guessing. + */ + data class Ambiguous( + val candidates: List, + ) : ArtifactHead +} + +/** + * Resolves an artifact's current head from its revisions the way a Buzz relay defines it: the + * latest *accepted* revision, where acceptance is a compare-and-swap on `prev` — every + * revision after the `create` names the head it replaced, so the accepted revisions form one + * linear chain. **Timestamps never choose the winner** (NIP-AR, "Identity and edits"), so + * neither does this resolver. + * + * The head is the one revision no other revision names as `prev` (a *tip*). Readers can see + * gaps — a NIP-29 `9005` redaction withholds a revision but keeps it accepted, retention + * expires old ones, and a moved artifact's earlier revisions stay under the source channel's + * access rules — which leaves more than one tip. Because the accepted history is linear, the + * segment that starts at the `create` is always the oldest, so when some other segment starts + * after a gap the create-rooted tip cannot be the head and is discarded. Anything still + * unresolved is reported as [ArtifactHead.Ambiguous] rather than picked by `created_at`. + * + * Ground truth: `accept_artifact` in Buzz's `buzz-db/src/store/artifact.rs` (the CAS), + * `removal_marker` in the same file, and `docs/nips/NIP-AR.md`. + */ +object ArtifactHeadResolver { + fun resolve( + artifactId: String, + revisions: Collection, + removals: Collection = emptyList(), + ): ArtifactHead { + val byId = LinkedHashMap() + val prevOf = HashMap() + val opOf = HashMap() + for (revision in revisions) { + if (revision.id in byId) continue + val envelope = revision.envelopeOrNull() ?: continue + if (envelope.id != artifactId) continue + byId[revision.id] = revision + prevOf[revision.id] = envelope.prev + opOf[revision.id] = envelope.op + } + if (byId.isEmpty()) return ArtifactHead.Unknown + + val replaced = HashSet() + prevOf.values.forEach { if (it != null) replaced.add(it) } + + val tips = byId.values.filter { it.id !in replaced } + if (tips.isEmpty()) return ArtifactHead.Ambiguous(emptyList()) + + // Walk each tip back to the oldest revision reachable from it. + val reachesCreate = tips.associateWith { tip -> opOf[segmentStart(tip.id, prevOf)] == ArtifactOp.CREATE } + + val head: ArtifactEvent + val complete: Boolean + if (tips.size == 1) { + head = tips[0] + complete = reachesCreate.getValue(head) + } else { + val afterGap = tips.filter { !reachesCreate.getValue(it) } + if (afterGap.size != 1) return ArtifactHead.Ambiguous(tips) + head = afterGap[0] + complete = false + } + + val removal = removals.firstOrNull { it.artifactId() == artifactId && it.replacedRevision() == head.id } + if (removal != null) return ArtifactHead.MovedAway(head, removal) + + return ArtifactHead.Current(head, complete) + } + + /** + * The known revisions from [head] back along `prev`, newest first. Stops at the `create`, + * at the first revision not in [revisions], or on a cycle. + */ + fun history( + head: ArtifactEvent, + revisions: Collection, + ): List { + val byId = revisions.associateBy { it.id } + val result = mutableListOf() + val seen = HashSet() + var current: ArtifactEvent? = head + while (current != null && seen.add(current.id)) { + result.add(current) + current = current.prev()?.let { byId[it] } + } + return result + } + + private fun segmentStart( + tip: HexKey, + prevOf: Map, + ): HexKey { + val seen = HashSet() + var current = tip + while (seen.add(current)) { + val prev = prevOf[current] ?: return current + if (prev !in prevOf) return current + current = prev + } + return current + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/arArtifacts/ArtifactIds.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/arArtifacts/ArtifactIds.kt new file mode 100644 index 0000000000..2de923680c --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/arArtifacts/ArtifactIds.kt @@ -0,0 +1,46 @@ +/* + * 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.buzz.arArtifacts + +/** + * The two identifier grammars NIP-AR's envelope uses, mirroring `canonical_uuid` and + * `event_id` in Buzz's `buzz-core/src/artifact.rs`. + */ +object ArtifactIds { + private val CANONICAL_UUID = Regex("^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$") + private const val NIL_UUID = "00000000-0000-0000-0000-000000000000" + + /** + * True for a lowercase, hyphenated, non-nil UUID. Upstream parses with `Uuid::parse_str` + * and then requires `id.to_string() == value`, which only the lowercase hyphenated form + * satisfies (simple, braced, URN and uppercase forms all fail the round trip). + */ + fun isCanonicalUuid(value: String): Boolean = value.length == 36 && CANONICAL_UUID.matches(value) && value != NIL_UUID + + /** True for a 64-character lowercase hex event id. */ + fun isEventId(value: String): Boolean { + if (value.length != 64) return false + for (c in value) { + if (c !in '0'..'9' && c !in 'a'..'f') return false + } + return true + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/arArtifacts/ArtifactRemovalEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/arArtifacts/ArtifactRemovalEvent.kt new file mode 100644 index 0000000000..c58f588d28 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/arArtifacts/ArtifactRemovalEvent.kt @@ -0,0 +1,93 @@ +/* + * 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.buzz.arArtifacts + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.buzz.arArtifacts.tags.ReasonTag +import com.vitorpamplona.quartz.buzz.arArtifacts.tags.VersionTag +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate +import com.vitorpamplona.quartz.utils.TimeUtils + +/** + * A Buzz NIP-AR artifact removal (`kind:45011`), **signed by the relay** and stored in the + * *source* channel when an artifact moves out of it, in the same transaction that accepts the + * `op=move` revision. It says only that artifact `d` left channel `h`, replacing revision + * `prev` — never the destination, title or content. Tags, in order: `["ar","1"]`, + * `["d",]`, `["h",]`, `["reason","moved"]`, + * `["prev",]`; `content` is empty. + * + * Clients never publish this kind; [build] exists for fixtures/tests. Ground truth: + * `removal_marker` in Buzz's `buzz-db/src/store/artifact.rs`. + */ +@Immutable +class ArtifactRemovalEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : Event(id, pubKey, createdAt, KIND, tags, content, sig) { + /** The artifact that left the channel — the `d` tag. */ + fun artifactId() = tags.artifactId() + + /** The source channel the artifact was removed from — the `h` tag. */ + fun sourceChannel() = tags.artifactHome() + + /** Why it was removed — the `reason` tag (`moved`). */ + fun reason() = tags.artifactRemovalReason() + + /** The revision the move replaced (the last one readable in the source) — the `prev` tag. */ + fun replacedRevision() = tags.artifactPrev() + + /** True when the marker has the exact shape the relay emits. */ + fun isWellFormed(): Boolean { + if (content.isNotEmpty()) return false + if (tags.artifactVersion() != VersionTag.CURRENT) return false + val id = artifactId() ?: return false + val source = sourceChannel() ?: return false + val prev = replacedRevision() ?: return false + return ArtifactIds.isCanonicalUuid(id) && ArtifactIds.isCanonicalUuid(source) && ArtifactIds.isEventId(prev) && reason() != null + } + + companion object { + const val KIND = 45011 + + fun build( + artifactId: String, + sourceChannelId: String, + replacedRevision: HexKey, + reason: String = ReasonTag.MOVED, + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate(KIND, "", createdAt) { + artifactVersion() + artifactId(artifactId) + artifactHome(sourceChannelId) + artifactRemovalReason(reason) + artifactPrev(replacedRevision) + initializer() + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/arArtifacts/TagArrayBuilderExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/arArtifacts/TagArrayBuilderExt.kt new file mode 100644 index 0000000000..3859edab58 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/arArtifacts/TagArrayBuilderExt.kt @@ -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.buzz.arArtifacts + +import com.vitorpamplona.quartz.buzz.arArtifacts.tags.ArtifactOp +import com.vitorpamplona.quartz.buzz.arArtifacts.tags.OpTag +import com.vitorpamplona.quartz.buzz.arArtifacts.tags.PrevTag +import com.vitorpamplona.quartz.buzz.arArtifacts.tags.ReasonTag +import com.vitorpamplona.quartz.buzz.arArtifacts.tags.RootTag +import com.vitorpamplona.quartz.buzz.arArtifacts.tags.TitleTag +import com.vitorpamplona.quartz.buzz.arArtifacts.tags.TypeTag +import com.vitorpamplona.quartz.buzz.arArtifacts.tags.VersionTag +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.tags.dTag.DTag +import com.vitorpamplona.quartz.nip29RelayGroups.tags.GroupIdTag + +/** The `ar` envelope-version tag. */ +fun TagArrayBuilder.artifactVersion(version: String = VersionTag.CURRENT) = addUnique(VersionTag.assemble(version)) + +/** + * The artifact UUID as its `d` tag. Artifacts are regular (not addressable) events, so this + * is a plain `d` tag, not the NIP-01 addressable identifier. + */ +fun TagArrayBuilder.artifactId(artifactId: String) = addUnique(DTag.assemble(artifactId)) + +/** The home (or, on a removal, source) channel — the `h` tag. */ +fun TagArrayBuilder.artifactHome(channelId: String) = addUnique(GroupIdTag.assemble(channelId)) + +fun TagArrayBuilder.artifactType(type: String) = addUnique(TypeTag.assemble(type)) + +fun TagArrayBuilder.artifactTitle(title: String) = addUnique(TitleTag.assemble(title)) + +fun TagArrayBuilder.artifactOp(op: ArtifactOp) = addUnique(OpTag.assemble(op)) + +fun TagArrayBuilder.artifactRoot(anchorEventId: HexKey) = addUnique(RootTag.assemble(anchorEventId)) + +fun TagArrayBuilder.artifactPrev(revisionId: HexKey) = addUnique(PrevTag.assemble(revisionId)) + +fun TagArrayBuilder.artifactRemovalReason(reason: String = ReasonTag.MOVED) = addUnique(ReasonTag.assemble(reason)) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/arArtifacts/TagArrayExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/arArtifacts/TagArrayExt.kt new file mode 100644 index 0000000000..82b23c17a6 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/arArtifacts/TagArrayExt.kt @@ -0,0 +1,69 @@ +/* + * 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.buzz.arArtifacts + +import com.vitorpamplona.quartz.buzz.arArtifacts.tags.ArtifactOp +import com.vitorpamplona.quartz.buzz.arArtifacts.tags.OpTag +import com.vitorpamplona.quartz.buzz.arArtifacts.tags.PrevTag +import com.vitorpamplona.quartz.buzz.arArtifacts.tags.ReasonTag +import com.vitorpamplona.quartz.buzz.arArtifacts.tags.RootTag +import com.vitorpamplona.quartz.buzz.arArtifacts.tags.TitleTag +import com.vitorpamplona.quartz.buzz.arArtifacts.tags.TypeTag +import com.vitorpamplona.quartz.buzz.arArtifacts.tags.VersionTag +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.TagArray +import com.vitorpamplona.quartz.nip01Core.core.fastFirstNotNullOfOrNull +import com.vitorpamplona.quartz.nip01Core.core.firstTagValue +import com.vitorpamplona.quartz.nip01Core.tags.dTag.DTag +import com.vitorpamplona.quartz.nip29RelayGroups.tags.GroupIdTag + +/** The envelope version — the `ar` tag. */ +fun TagArray.artifactVersion(): String? = fastFirstNotNullOfOrNull(VersionTag::parse) + +/** The artifact's stable UUID — the `d` tag. */ +fun TagArray.artifactId(): String? = firstTagValue(DTag.TAG_NAME) + +/** The artifact's home channel UUID (the source channel on a removal) — the `h` tag. */ +fun TagArray.artifactHome(): String? = fastFirstNotNullOfOrNull(GroupIdTag::parse) + +/** The namespaced content type — the `type` tag. */ +fun TagArray.artifactType(): String? = fastFirstNotNullOfOrNull(TypeTag::parse) + +/** The display title — the `title` tag (absent on a delete). */ +fun TagArray.artifactTitle(): String? = fastFirstNotNullOfOrNull(TitleTag::parse) + +/** The lifecycle operation — the `op` tag. */ +fun TagArray.artifactOp(): ArtifactOp? = fastFirstNotNullOfOrNull(OpTag::parse) + +/** The conversation anchor — the `root` tag. */ +fun TagArray.artifactRoot(): HexKey? = fastFirstNotNullOfOrNull(RootTag::parse) + +/** The replaced revision — the `prev` tag. */ +fun TagArray.artifactPrev(): HexKey? = fastFirstNotNullOfOrNull(PrevTag::parse) + +/** Why a removal (`kind:45011`) was issued — the `reason` tag. */ +fun TagArray.artifactRemovalReason(): String? = fastFirstNotNullOfOrNull(ReasonTag::parse) + +/** + * The client-defined tags: every tag that is neither envelope nor NIP-OA `auth`. NIP-AR + * editors MUST preserve these (and unfamiliar content fields) or decline the edit. + */ +fun TagArray.artifactClientTags(): List> = filter { it.isNotEmpty() && it[0] !in ArtifactValidator.ENVELOPE_TAG_NAMES && it[0] != ArtifactValidator.AUTH_TAG_NAME } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/arArtifacts/tags/ArtifactOp.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/arArtifacts/tags/ArtifactOp.kt new file mode 100644 index 0000000000..31142f1b00 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/arArtifacts/tags/ArtifactOp.kt @@ -0,0 +1,57 @@ +/* + * 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.buzz.arArtifacts.tags + +/** + * The lifecycle operation a NIP-AR revision performs, carried by the `op` tag ([OpTag]). + * Ground truth: `ArtifactOp` in Buzz's `buzz-core/src/artifact.rs`. + */ +enum class ArtifactOp( + val code: String, +) { + /** First revision of a new identity; the only op without `prev`. */ + CREATE("create"), + + /** Content change within the same home channel. */ + UPDATE("update"), + + /** Content snapshot published into a new home channel. */ + MOVE("move"), + + /** Soft delete: empty content, no `title`; only [RESTORE] may follow. */ + DELETE("delete"), + + /** Complete snapshot that revives a deleted artifact. */ + RESTORE("restore"), + ; + + companion object { + fun parse(code: String): ArtifactOp? = + when (code) { + CREATE.code -> CREATE + UPDATE.code -> UPDATE + MOVE.code -> MOVE + DELETE.code -> DELETE + RESTORE.code -> RESTORE + else -> null + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/arArtifacts/tags/OpTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/arArtifacts/tags/OpTag.kt new file mode 100644 index 0000000000..b9d520515c --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/arArtifacts/tags/OpTag.kt @@ -0,0 +1,40 @@ +/* + * 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.buzz.arArtifacts.tags + +import com.vitorpamplona.quartz.nip01Core.core.Tag +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.ensure + +/** The NIP-AR `op` tag — the revision's [ArtifactOp]. */ +object OpTag { + const val TAG_NAME = "op" + + fun match(tag: Tag) = tag.has(1) && tag[0] == TAG_NAME + + fun parse(tag: Tag): ArtifactOp? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + return ArtifactOp.parse(tag[1]) + } + + fun assemble(op: ArtifactOp) = arrayOf(TAG_NAME, op.code) +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/arArtifacts/tags/PrevTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/arArtifacts/tags/PrevTag.kt new file mode 100644 index 0000000000..3490b5c6bc --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/arArtifacts/tags/PrevTag.kt @@ -0,0 +1,48 @@ +/* + * 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.buzz.arArtifacts.tags + +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.Tag +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.ensure + +/** + * The NIP-AR `prev` tag — the id of the revision this one replaces. Required on every op + * except `create`, where it MUST be absent; the relay accepts a revision only when `prev` + * names the artifact's current head (compare-and-swap). The relay-signed removal + * (`kind:45011`) reuses it for the revision a move replaced. Ground truth: `validate` in + * `buzz-core/src/artifact.rs`, `accept_artifact` in `buzz-db/src/store/artifact.rs`. + */ +object PrevTag { + const val TAG_NAME = "prev" + + fun match(tag: Tag) = tag.has(1) && tag[0] == TAG_NAME + + fun parse(tag: Tag): HexKey? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + return tag[1] + } + + fun assemble(revisionId: HexKey) = arrayOf(TAG_NAME, revisionId) +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/arArtifacts/tags/ReasonTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/arArtifacts/tags/ReasonTag.kt new file mode 100644 index 0000000000..12eddfa775 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/arArtifacts/tags/ReasonTag.kt @@ -0,0 +1,45 @@ +/* + * 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.buzz.arArtifacts.tags + +import com.vitorpamplona.quartz.nip01Core.core.Tag +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.ensure + +/** + * The `reason` tag on a relay-signed NIP-AR removal (`kind:45011`). The relay only ever + * emits [MOVED]. Ground truth: `removal_marker` in Buzz's `buzz-db/src/store/artifact.rs`. + */ +object ReasonTag { + const val TAG_NAME = "reason" + const val MOVED = "moved" + + fun match(tag: Tag) = tag.has(1) && tag[0] == TAG_NAME + + fun parse(tag: Tag): String? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + return tag[1] + } + + fun assemble(reason: String = MOVED) = arrayOf(TAG_NAME, reason) +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/arArtifacts/tags/RootTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/arArtifacts/tags/RootTag.kt new file mode 100644 index 0000000000..052229af6a --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/arArtifacts/tags/RootTag.kt @@ -0,0 +1,48 @@ +/* + * 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.buzz.arArtifacts.tags + +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.Tag +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.ensure + +/** + * The NIP-AR `root` tag — an optional conversation anchor (a `kind:9`, `40002`, `45001` or + * `45003` event in the home channel) the artifact is attached to. When set on creation, or + * changed from the previous revision, the relay requires the anchor to exist, undeleted, in + * `h`; a delete must keep the current value. Ground truth: `accept_artifact` in + * `buzz-db/src/store/artifact.rs`. + */ +object RootTag { + const val TAG_NAME = "root" + + fun match(tag: Tag) = tag.has(1) && tag[0] == TAG_NAME + + fun parse(tag: Tag): HexKey? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + return tag[1] + } + + fun assemble(anchorEventId: HexKey) = arrayOf(TAG_NAME, anchorEventId) +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/arArtifacts/tags/TitleTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/arArtifacts/tags/TitleTag.kt new file mode 100644 index 0000000000..8705d27174 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/arArtifacts/tags/TitleTag.kt @@ -0,0 +1,49 @@ +/* + * 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.buzz.arArtifacts.tags + +import com.vitorpamplona.quartz.nip01Core.core.Tag +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.ensure + +/** + * The NIP-AR `title` tag — the artifact's display title. Required, nonblank and at most + * [MAX_BYTES] UTF-8 bytes on every revision except `op=delete`, where it MUST be absent. + * Ground truth: `validate` in Buzz's `buzz-core/src/artifact.rs`. + */ +object TitleTag { + const val TAG_NAME = "title" + const val MAX_BYTES = 512 + + fun match(tag: Tag) = tag.has(1) && tag[0] == TAG_NAME + + fun parse(tag: Tag): String? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + return tag[1] + } + + fun assemble(title: String) = arrayOf(TAG_NAME, title) + + /** Nonblank and within [MAX_BYTES] UTF-8 bytes. */ + fun isValid(title: String) = title.isNotBlank() && title.encodeToByteArray().size <= MAX_BYTES +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/arArtifacts/tags/TypeTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/arArtifacts/tags/TypeTag.kt new file mode 100644 index 0000000000..73b49e9fd0 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/arArtifacts/tags/TypeTag.kt @@ -0,0 +1,67 @@ +/* + * 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.buzz.arArtifacts.tags + +import com.vitorpamplona.quartz.nip01Core.core.Tag +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.ensure + +/** + * The NIP-AR `type` tag — the artifact's immutable, namespaced content type (e.g. + * `buzz.task`, `buzz.project`). `buzz.*` is reserved for published Buzz client contracts. + * Ground truth: `validate` in Buzz's `buzz-core/src/artifact.rs`. + */ +object TypeTag { + const val TAG_NAME = "type" + const val MAX_BYTES = 128 + + fun match(tag: Tag) = tag.has(1) && tag[0] == TAG_NAME + + fun parse(tag: Tag): String? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + return tag[1] + } + + fun assemble(type: String) = arrayOf(TAG_NAME, type) + + /** + * The type grammar: at most [MAX_BYTES] bytes, at least one `.`, and every dot-separated + * component non-empty, starting with `a-z`, and otherwise only `a-z`, `0-9`, `_` or `-`. + */ + fun isValid(type: String): Boolean { + if (type.length > MAX_BYTES || '.' !in type) return false + var componentStart = true + for (c in type) { + if (c == '.') { + if (componentStart) return false + componentStart = true + } else if (componentStart) { + if (c !in 'a'..'z') return false + componentStart = false + } else if (c !in 'a'..'z' && c !in '0'..'9' && c != '_' && c != '-') { + return false + } + } + return !componentStart + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/arArtifacts/tags/VersionTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/arArtifacts/tags/VersionTag.kt new file mode 100644 index 0000000000..d6f90ce46a --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/arArtifacts/tags/VersionTag.kt @@ -0,0 +1,47 @@ +/* + * 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.buzz.arArtifacts.tags + +import com.vitorpamplona.quartz.nip01Core.core.Tag +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.ensure + +/** + * The NIP-AR `ar` tag — the artifact envelope version. Only [CURRENT] (`"1"`) is defined; + * relays reject any other value ("unsupported artifact envelope version"). Carried by both + * the revision (`kind:45010`) and the relay-signed removal (`kind:45011`). Ground truth: + * `validate` in Buzz's `buzz-core/src/artifact.rs`. + */ +object VersionTag { + const val TAG_NAME = "ar" + const val CURRENT = "1" + + fun match(tag: Tag) = tag.has(1) && tag[0] == TAG_NAME + + fun parse(tag: Tag): String? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + return tag[1] + } + + fun assemble(version: String = CURRENT) = arrayOf(TAG_NAME, version) +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/cwChannelWindow/ThreadWindowBoundsContent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/cwChannelWindow/ThreadWindowBoundsContent.kt new file mode 100644 index 0000000000..cab6a0f859 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/cwChannelWindow/ThreadWindowBoundsContent.kt @@ -0,0 +1,61 @@ +/* + * 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.buzz.cwChannelWindow + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.decodeFromString +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json + +/** + * The JSON `content` of a Buzz NIP-CW thread-bounds overlay (`kind:39007`): + * `{"version":1,"direction":"older","has_more":,"next_cursor":{"created_at":,"id":}|null}`. + * It is the sole authority on a thread window's exhaustion; clients echo [nextCursor] back as + * `until` + `before_id` to request the next (older) page. [isConsistent] carries the NIP's + * client-side checks on the body. Ground truth: `buzz-relay/src/api/bridge/thread_window.rs`. + */ +@Serializable +data class ThreadWindowBoundsContent( + val version: Int = VERSION, + val direction: String = DIRECTION_OLDER, + @SerialName("has_more") val hasMore: Boolean, + @SerialName("next_cursor") val nextCursor: NextCursor? = null, +) { + fun encodeToJson(): String = JSON.encodeToString(this) + + /** Version 1, direction `older`, and a cursor present exactly when [hasMore]. */ + fun isConsistent() = version == VERSION && direction == DIRECTION_OLDER && (nextCursor != null) == hasMore + + companion object { + const val VERSION = 1 + const val DIRECTION_OLDER = "older" + + // The relay writes `"next_cursor":null` on an exhausted page, so nulls stay explicit. + val JSON = + Json { + ignoreUnknownKeys = true + encodeDefaults = true + } + + fun decodeFromJson(json: String): ThreadWindowBoundsContent = JSON.decodeFromString(json) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/cwChannelWindow/ThreadWindowBoundsEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/cwChannelWindow/ThreadWindowBoundsEvent.kt new file mode 100644 index 0000000000..e4512757f1 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/cwChannelWindow/ThreadWindowBoundsEvent.kt @@ -0,0 +1,145 @@ +/* + * 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.buzz.cwChannelWindow + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.core.BaseAddressableEvent +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate +import com.vitorpamplona.quartz.nip01Core.tags.dTag.DTag +import com.vitorpamplona.quartz.nip01Core.tags.dTag.dTag +import com.vitorpamplona.quartz.nip01Core.tags.events.ETag +import com.vitorpamplona.quartz.nip29RelayGroups.tags.GroupIdTag +import com.vitorpamplona.quartz.utils.TimeUtils +import kotlinx.coroutines.CancellationException + +/** + * A Buzz NIP-CW thread-bounds overlay (`kind:39007`), **signed by the relay** and synthesized + * per thread-mode query (never stored; the relay rejects it at ingest). Exactly one is + * appended to every served thread window — including empty and exhausted pages — and it is + * the only authority on the thread window's exhaustion. Deliberately distinct from the + * channel-mode [WindowBoundsEvent] (`39006`), whose channel/cursor key cannot tell concurrent + * roots apart. + * + * Tags are exactly `["d","tw:1:"]` (the request binding, see + * [ThreadWindowRequest.binding]), `["h",]` and `["e",]`; `content` is a + * [ThreadWindowBoundsContent]. Before trusting it a client MUST check the relay signer and + * signature (the caller's job), the exact tags and binding ([matches]), and the body + * ([ThreadWindowBoundsContent.isConsistent]). Clients never publish this kind; [build] exists + * for fixtures/tests. Ground truth: `buzz-relay/src/api/bridge/thread_window.rs`, + * `buzz-core/src/thread_window.rs`, `docs/nips/NIP-CW.md` §Thread Bounds. + */ +@Immutable +class ThreadWindowBoundsEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig) { + /** The request binding — the `d` tag, `tw:1:`. */ + fun binding() = dTag() + + /** The channel id — the `h` tag. */ + fun channelId() = tags.firstNotNullOfOrNull(GroupIdTag::parse) + + /** The thread root id — the `e` tag. */ + fun rootId() = tags.firstNotNullOfOrNull(ETag::parseId) + + /** Parses the JSON bounds [content]. Throws on malformed content; use [boundsOrNull]. */ + fun bounds(): ThreadWindowBoundsContent = ThreadWindowBoundsContent.decodeFromJson(content) + + fun boundsOrNull(): ThreadWindowBoundsContent? = + try { + bounds() + } catch (e: Exception) { + if (e is CancellationException) throw e + null + } + + /** + * True when the tags are exactly one two-element `d`, `h` and `e` (and nothing else), the + * `d` is a `tw:1:` binding, the `h` a lowercase hyphenated UUID, the `e` a lowercase + * 64-hex id, and the body is consistent. Says nothing about which request it answers — + * use [matches] for that. + */ + fun isWellFormed(): Boolean { + if (tags.size != 3) return false + var d: String? = null + var h: String? = null + var e: String? = null + for (tag in tags) { + if (tag.size != 2) return false + when (tag[0]) { + DTag.TAG_NAME -> if (d == null) d = tag[1] else return false + GroupIdTag.TAG_NAME -> if (h == null) h = tag[1] else return false + ETag.TAG_NAME -> if (e == null) e = tag[1] else return false + else -> return false + } + } + if (d == null || h == null || e == null) return false + if (!d.startsWith(ThreadWindowRequest.BINDING_PREFIX) || !isLowercaseHex(d.substring(ThreadWindowRequest.BINDING_PREFIX.length), 64)) return false + if (!CANONICAL_UUID.matches(h) || !isLowercaseHex(e, 64)) return false + return boundsOrNull()?.isConsistent() == true + } + + /** + * True when this well-formed overlay answers [request], as issued by [readerPubKey] to the + * relay at [host]: the binding, channel and root all match. + */ + fun matches( + request: ThreadWindowRequest, + host: String, + readerPubKey: HexKey, + ): Boolean = + isWellFormed() && + binding() == request.binding(host, readerPubKey) && + channelId() == request.channelId.lowercase() && + rootId() == request.rootId.lowercase() + + companion object { + const val KIND = 39007 + + private val CANONICAL_UUID = Regex("^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$") + + private fun isLowercaseHex( + value: String, + length: Int, + ) = value.length == length && value.all { it in '0'..'9' || it in 'a'..'f' } + + /** Builds the overlay the relay would sign for [request] (fixtures/tests only). */ + fun build( + request: ThreadWindowRequest, + host: String, + readerPubKey: HexKey, + bounds: ThreadWindowBoundsContent, + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate(KIND, bounds.encodeToJson(), createdAt) { + dTag(request.binding(host, readerPubKey)) + addUnique(GroupIdTag.assemble(request.channelId.lowercase())) + addUnique(ETag.assemble(request.rootId.lowercase(), null, null)) + initializer() + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/cwChannelWindow/ThreadWindowRequest.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/cwChannelWindow/ThreadWindowRequest.kt new file mode 100644 index 0000000000..549bfffd82 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/cwChannelWindow/ThreadWindowRequest.kt @@ -0,0 +1,97 @@ +/* + * 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.buzz.cwChannelWindow + +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.utils.sha256.sha256 +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.JsonPrimitive + +/** + * The response-affecting arguments of a NIP-CW thread-mode request (`thread_window: true`, + * served on Buzz's NIP-98 HTTP `POST /query`), normalized the way the relay normalizes them, + * so a client can recompute the request binding a [ThreadWindowBoundsEvent] must carry. + * + * [kinds] must be drawn from `9`, `40002`, `45001`, `45003` (sorted and deduplicated here); + * [limit] is 1–200 (default 50), [depth] 1–100 (default 100); [cursor] is null for the head + * page, else the previous bounds' `next_cursor`. Ground truth: `Request::parse` and + * `Request::binding` in Buzz's `buzz-core/src/thread_window.rs`. + */ +data class ThreadWindowRequest( + val channelId: String, + val rootId: HexKey, + val kinds: List, + val limit: Int = DEFAULT_LIMIT, + val depth: Int = MAX_DEPTH, + val cursor: NextCursor? = null, + val includeAux: Boolean = false, +) { + init { + require(kinds.isNotEmpty() && kinds.all { it in ROW_KINDS }) { "thread_window supports row kinds 9, 40002, 45001, 45003 only" } + require(limit in 1..MAX_LIMIT) { "thread_window: limit must be an integer in 1..=$MAX_LIMIT" } + require(depth in 1..MAX_DEPTH) { "thread_window: depth_limit must be an integer in 1..=$MAX_DEPTH" } + require(rootId.length == 64 && rootId.all { it in '0'..'9' || it in 'a'..'f' || it in 'A'..'F' }) { "thread_window: expected a full 64-hex event id" } + require(cursor == null || cursor.createdAt >= 0) { "thread_window: until must be nonnegative integer seconds" } + } + + /** + * The `d` value of the bounds event answering this request for [readerPubKey] on [host]: + * `"tw:1:" + hex(SHA-256(compact JSON of + * ["tw",1,"older",host,reader,channel,root,limit,depth,kinds,cursor,include_aux]))`, where + * `cursor` is `null` or `[created_at,""]`. [host] is the relay's normalized authority + * (e.g. `relay.example` or `localhost:3000`); ids and the reader are lowercased first. + */ + fun binding( + host: String, + readerPubKey: HexKey, + ): String { + val canonical = + JsonArray( + listOf( + JsonPrimitive("tw"), + JsonPrimitive(1), + JsonPrimitive(ThreadWindowBoundsContent.DIRECTION_OLDER), + JsonPrimitive(host), + JsonPrimitive(readerPubKey.lowercase()), + JsonPrimitive(channelId.lowercase()), + JsonPrimitive(rootId.lowercase()), + JsonPrimitive(limit), + JsonPrimitive(depth), + JsonArray(kinds.distinct().sorted().map { JsonPrimitive(it) }), + cursor?.let { JsonArray(listOf(JsonPrimitive(it.createdAt), JsonPrimitive(it.id.lowercase()))) } ?: JsonNull, + JsonPrimitive(includeAux), + ), + ) + return BINDING_PREFIX + sha256(canonical.toString().encodeToByteArray()).toHexKey() + } + + companion object { + const val BINDING_PREFIX = "tw:1:" + const val DEFAULT_LIMIT = 50 + const val MAX_LIMIT = 200 + const val MAX_DEPTH = 100 + + /** Conversation row kinds a thread window may request. */ + val ROW_KINDS = setOf(9, 40002, 45001, 45003) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/huddles/HuddleLifecycleContent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/huddles/HuddleLifecycleContent.kt index 756e48cb61..9d28309e21 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/huddles/HuddleLifecycleContent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/huddles/HuddleLifecycleContent.kt @@ -29,12 +29,19 @@ import kotlinx.serialization.json.Json /** * The `content` JSON shared by every Buzz huddle-lifecycle event (`kind:48100`–`48103`): * the id of the ephemeral audio channel the lifecycle transition applies to. The parent - * (timeline) channel is carried separately in the `h` tag. Field name is snake_case on + * (timeline) channel is carried separately in the `h` tag. Field names are snake_case on * the wire. Ground truth: `buzz-relay/src/audio/handler.rs::emit_participant_event`. + * + * The relay also stamps the huddle session's [generation] (the same value the kind-48104 + * liveness answer carries, so a client can tell a restarted session from the old one) and, + * when it has them, the participant roster's [rosterRevision] and the joiner's [admissionId]. */ @Serializable data class HuddleLifecycleContent( @SerialName("ephemeral_channel_id") val ephemeralChannelId: String, + @SerialName("roster_revision") val rosterRevision: Long? = null, + @SerialName("admission_id") val admissionId: String? = null, + val generation: String? = null, ) { fun encodeToJson(): String = JSON.encodeToString(this) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/huddles/HuddleLivenessContent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/huddles/HuddleLivenessContent.kt new file mode 100644 index 0000000000..0854b8cca0 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/huddles/HuddleLivenessContent.kt @@ -0,0 +1,90 @@ +/* + * 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.buzz.huddles + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.decodeFromString +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json + +/** + * The `content` JSON of a relay-synthesized huddle-liveness snapshot (`kind:48104`): + * `{"ephemeral_channel_id": , "generation": }`. + * + * [generation] is a **string** on the wire (the relay `to_string()`s it): the mesh lease + * generation (a monotonic decimal integer) when the relay runs a mesh, otherwise an opaque + * per-process epoch. Compare with [compareGenerations], never numerically on a `Long`. + * Ground truth: `handle_huddle_liveness_req` in Buzz's `buzz-relay/src/handlers/req.rs`; + * reader in `desktop/src/features/huddle/lib/huddlePresence.ts`. + */ +@Serializable +data class HuddleLivenessContent( + @SerialName("ephemeral_channel_id") val ephemeralChannelId: String, + val generation: String, +) { + fun encodeToJson(): String = JSON.encodeToString(this) + + companion object { + val JSON = + Json { + ignoreUnknownKeys = true + explicitNulls = false + encodeDefaults = true + } + + fun decodeFromJson(json: String): HuddleLivenessContent = JSON.decodeFromString(json) + + fun decodeFromJsonOrNull(json: String): HuddleLivenessContent? = + try { + decodeFromJson(json) + } catch (_: Exception) { + null + } + + /** + * Orders two liveness generations the way Buzz's desktop does + * (`compareHuddleGenerations`): only when both are plain decimal integers (mesh + * generations, which are monotonic) is there an order, returned as -1/0/1 for + * [candidate] below/equal/above [current]. Anything else is an opaque epoch and yields + * null — a differing opaque epoch means "take a fresh snapshot", not "newer". + */ + fun compareGenerations( + candidate: String, + current: String, + ): Int? { + if (!candidate.isDecimal() || !current.isDecimal()) return null + val a = candidate.trimStart('0') + val b = current.trimStart('0') + if (a.length != b.length) return if (a.length < b.length) -1 else 1 + val cmp = a.compareTo(b) + return if (cmp < 0) { + -1 + } else if (cmp > 0) { + 1 + } else { + 0 + } + } + + private fun String.isDecimal() = isNotEmpty() && all { it in '0'..'9' } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/huddles/HuddleLivenessEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/huddles/HuddleLivenessEvent.kt new file mode 100644 index 0000000000..a94f7359c8 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/huddles/HuddleLivenessEvent.kt @@ -0,0 +1,117 @@ +/* + * 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.buzz.huddles + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.core.firstTagValue +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate +import com.vitorpamplona.quartz.nip01Core.tags.dTag.DTag +import com.vitorpamplona.quartz.nip29RelayGroups.tags.GroupIdTag +import com.vitorpamplona.quartz.utils.TimeUtils + +/** + * A Buzz huddle-liveness snapshot (`kind:48104`): the relay's authoritative statement that a + * huddle session is live right now, **synthesized and signed by the relay** per REQ and + * never stored. One event per live session: `["d",]` (the ephemeral audio + * channel), `["h",]`, and a [HuddleLivenessContent] body. A session + * with no live room (or no mesh lease) simply gets no event, so absence from a snapshot is + * "not live". + * + * The relay only serves it to a REQ whose every filter has `kinds` exactly `[48104]` and + * that names at least one authorized `#h` channel — build it with [filter]. Clients never + * publish this kind; [build] exists for fixtures/tests. Ground truth: + * `filters_are_huddle_liveness_only`, `huddle_liveness_session_ids` and + * `handle_huddle_liveness_req` in Buzz's `buzz-relay/src/handlers/req.rs`. + */ +@Immutable +class HuddleLivenessEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : Event(id, pubKey, createdAt, KIND, tags, content, sig) { + /** The live huddle session (its ephemeral audio channel UUID) — the `d` tag. */ + fun sessionId(): String? = tags.firstTagValue(DTag.TAG_NAME) + + /** The parent (timeline) channel UUID — the `h` tag. */ + fun channelId(): String? = tags.huddleChannel() + + /** The parsed body, or null when malformed (a malformed snapshot is not authoritative). */ + fun liveness(): HuddleLivenessContent? = HuddleLivenessContent.decodeFromJsonOrNull(content) + + /** The session's liveness generation, or null when missing/empty. */ + fun generation(): String? = liveness()?.generation?.ifEmpty { null } + + companion object { + const val KIND = 48104 + + /** The relay truncates `#d` session ids (and bounds `#h` channels) to this many values. */ + const val MAX_EXPLICIT_VALUES = 128 + + /** + * The liveness REQ filter: `kinds` exactly `[48104]`, the parent channels as `#h` + * (required — the relay CLOSEs a liveness REQ without an authorized `#h`), and + * optionally the sessions of interest as `#d` with a matching `limit`, the shape + * Buzz's desktop sends. Without [sessionIds] the relay reports every live session it + * can link to those channels. Mixing any other kind into the filter turns it into an + * ordinary REQ that returns no liveness at all. + */ + fun filter( + channelIds: List, + sessionIds: List? = null, + ): Filter { + require(channelIds.isNotEmpty()) { "huddle liveness requires at least one #h channel" } + require(channelIds.size <= MAX_EXPLICIT_VALUES) { "at most $MAX_EXPLICIT_VALUES channels per liveness REQ" } + require(sessionIds == null || sessionIds.size <= MAX_EXPLICIT_VALUES) { "at most $MAX_EXPLICIT_VALUES sessions per liveness REQ" } + + val tags = + if (sessionIds.isNullOrEmpty()) { + mapOf(GroupIdTag.TAG_NAME to channelIds) + } else { + mapOf(GroupIdTag.TAG_NAME to channelIds, DTag.TAG_NAME to sessionIds) + } + + return Filter( + kinds = listOf(KIND), + tags = tags, + limit = sessionIds?.size?.takeIf { it > 0 }, + ) + } + + fun build( + sessionId: String, + channelId: String, + generation: String, + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate(KIND, HuddleLivenessContent(sessionId, generation).encodeToJson(), createdAt) { + add(DTag.assemble(sessionId)) + huddleChannel(channelId) + initializer() + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/identityNames/IdentityNamePolicy.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/identityNames/IdentityNamePolicy.kt new file mode 100644 index 0000000000..3d25e21919 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/identityNames/IdentityNamePolicy.kt @@ -0,0 +1,214 @@ +/* + * 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.buzz.identityNames + +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip19Bech32.entities.NPub + +/** + * One naming fact for an identity: its [pubkey], the [name] a client would show, whether it + * [isAgent], and the [ownerPubkey] an agent declares (a verified NIP-OA owner). Keys are 64-char hex, + * either case. + */ +data class NamingIdentity( + val pubkey: HexKey, + val name: String, + val isAgent: Boolean = false, + val ownerPubkey: HexKey? = null, +) + +/** A resolved display label. [qualifier] is the key suffix, when one was added. */ +data class ResolvedIdentityName( + val name: String, + val qualifier: String? = null, +) + +/** + * Buzz's "contextual identity names" v1: distinct display labels for the identities one view shows + * (a channel's members, a mention picker's choices), so two identities with the same name never look + * identical. A label only grows when a real collision exists in that context: + * + * 1. Within a collision, the highest-priority identity keeps its plain name: the viewer, then other + * people, then the viewer's own agents, then everyone else's agents. + * 2. A colliding agent first becomes readable: `Alice’s Honey` (its owner's name) when it is not the + * viewer's, else `Honey (agent)` when a person shares the name. + * 3. Whoever still collides gets a growing suffix of their npub: `Honey · 7xk2`. + * + * Display policy only; a label grants no authority, so keep the key for every action. Ported from + * Buzz's `mobile/lib/shared/identity_names/identity_name_policy.dart` and checked against its + * vendored portable fixtures (`IdentityNamePolicyTest`). + */ +object IdentityNamePolicy { + const val VERSION = 1 + + private val HEX_KEY = Regex("^[0-9a-fA-F]{64}$") + + // ECMAScript String.prototype.trim code points, as the contract requires (U+0085 is kept). + private fun isContractWhitespace(c: Char): Boolean { + val u = c.code + return (u in 0x09..0x0D) || + u == 0x20 || + u == 0xA0 || + u == 0x1680 || + (u in 0x2000..0x200A) || + u == 0x2028 || + u == 0x2029 || + u == 0x202F || + u == 0x205F || + u == 0x3000 || + u == 0xFEFF + } + + /** Trims [value] exactly as the contract specifies. */ + fun trim(value: String): String { + var start = 0 + var end = value.length + while (start < end && isContractWhitespace(value[start])) start++ + while (end > start && isContractWhitespace(value[end - 1])) end-- + return value.substring(start, end) + } + + private fun requireKey( + key: String, + what: String, + ) = require(HEX_KEY.matches(key)) { "$what is not a 64-character hex public key: $key" } + + private class Row( + val key: String, + val identity: NamingIdentity, + val original: String, + val priority: Int, + val mine: Boolean, + ) { + var base: String = original + var label: String = original + var length: Int = 0 + var suffix: String? = null + } + + /** + * Resolves distinct labels for the [candidates] among [identities] (all of them when + * [candidates] is null; none for an empty set), as seen by [viewer]. Facts outside the + * candidates still serve as owner-name lookups. Throws on any invalid key. + */ + fun resolve( + identities: List, + viewer: HexKey? = null, + candidates: Iterable? = null, + ): Map { + identities.forEach { identity -> + requireKey(identity.pubkey, "pubkey") + identity.ownerPubkey?.let { requireKey(it, "ownerPubkey") } + } + viewer?.let { requireKey(it, "viewer") } + val selected = + candidates + ?.map { + requireKey(it, "candidates") + it.lowercase() + }?.toSet() + val normalizedViewer = viewer?.lowercase() + + // Last fact per key: the preferred alias and the owner-lookup name. + val preferred = LinkedHashMap() + identities.forEach { preferred[it.pubkey.lowercase()] = it } + + // One working row per (key, trimmed name); the last fact supplies metadata. + val aliases = LinkedHashMap, NamingIdentity>() + identities.forEach { aliases[it.pubkey.lowercase() to trim(it.name)] = it } + + val rows = + aliases.mapNotNull { (keyAndName, identity) -> + val (key, name) = keyAndName + if (selected != null && key !in selected) return@mapNotNull null + val owner = identity.ownerPubkey?.lowercase() + val mine = normalizedViewer != null && (key == normalizedViewer || owner == normalizedViewer) + Row( + key = key, + identity = identity, + original = name, + mine = mine, + priority = + if (identity.isAgent) { + if (mine) 2 else 3 + } else { + if (key == normalizedViewer) 0 else 1 + }, + ) + } + + val npubs = HashMap() + + fun npubFor(key: String) = npubs.getOrPut(key) { NPub.create(key) } + + while (true) { + val groups = LinkedHashMap>() + rows.forEach { groups.getOrPut(it.label) { mutableListOf() }.add(it) } + val collisions = groups.values.filter { group -> group.map { it.key }.toSet().size > 1 } + if (collisions.isEmpty()) break + + for (group in collisions) { + val best = group.minOf { it.priority } + val winners = group.filter { it.priority == best }.map { it.key }.toSet() + val changing = group.filter { winners.size != 1 || it.key !in winners } + val groupHasHuman = group.any { !it.identity.isAgent } + + var qualified = false + for (row in changing) { + if (!row.identity.isAgent || row.length != 0) continue + val ownerFact = + row.identity.ownerPubkey + ?.lowercase() + ?.let { preferred[it] } + val owner = ownerFact?.let { trim(it.name) } ?: "" + val readable = + when { + !row.mine && owner.isNotEmpty() -> "$owner’s ${row.original}" + groupHasHuman -> "${row.original} (agent)" + else -> row.base + } + if (readable != row.base) { + row.base = readable + row.label = readable + qualified = true + } + } + if (qualified) continue + + for (row in changing) { + row.length = if (row.length == 0) 4 else row.length + 1 + val npub = npubFor(row.key) + val suffix = if (row.length <= npub.length) npub.substring(npub.length - row.length) else "$npub · ${row.length - npub.length}" + row.suffix = suffix + row.label = "${row.base} · $suffix" + } + } + } + + val result = LinkedHashMap() + rows.forEach { row -> + if (row.original == trim(preferred.getValue(row.key).name)) { + result[row.key] = ResolvedIdentityName(row.label, row.suffix) + } + } + return result + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/invite/BuzzInviteLink.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/invite/BuzzInviteLink.kt index 1f1b0fbc37..828986034e 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/invite/BuzzInviteLink.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/invite/BuzzInviteLink.kt @@ -31,16 +31,19 @@ import kotlin.io.encoding.ExperimentalEncodingApi * * - `.` (base64url, JWT-style but not a JWT) — the payload names the * community, the granted role, an expiry and a nonce. - * - `v2.` — a bare server-side handle. Nothing about the invite is readable here; - * the relay resolves it on claim. + * - `v2.` — a bare server-side handle: `v2.` plus the unpadded, canonical base64url + * encoding of a 32-byte secret (exactly what `validate_v2_code` in `buzz-core/src/invite.rs` + * accepts — see [BuzzInviteLink.isValidV2Code]). Nothing about the invite is readable here; the + * relay resolves it on claim. * * A Buzz invite is **not** a NIP-29 invite code (kind 9009) — it is redeemed over HTTP against * the relay's tenant host: `POST /api/invites/claim`, NIP-98-signed by the joining key, after * accepting any configured join policy. The relay verifies the token's MAC (its own key), so a * client only needs to read the payload, never validate the signature. * - * Ground truth: `buzz-relay/src/invite_token.rs` (token shape + `verify_invite`) and - * `buzz-relay/src/api/invites.rs` (`claim_invite`, `accept_policy`). + * Ground truth: `buzz-relay/src/invite_token.rs` (token shape + `verify_invite`), + * `buzz-core/src/invite.rs` (`validate_v2_code`) and `buzz-relay/src/api/invites.rs` + * (`claim_invite`, `accept_policy`). */ data class BuzzInvite( /** The tenant host the invite is scoped to, e.g. `team.communities.buzz.xyz`. */ @@ -74,6 +77,15 @@ object BuzzInviteLink { /** The payload segment of an opaque, server-resolved token. */ private const val V2_PREFIX = "v2" + /** The full prefix the relay routes on (`V2_PREFIX` in `buzz-core/src/invite.rs`). */ + const val V2_CODE_PREFIX = "v2." + + /** Random bytes behind a `v2.` code (`V2_SECRET_LEN` in `buzz-core/src/invite.rs`). */ + const val V2_SECRET_LEN = 32 + + @OptIn(ExperimentalEncodingApi::class) + private val B64_NO_PAD = Base64.UrlSafe.withPadding(Base64.PaddingOption.ABSENT) + /** What the relay grants when the token doesn't say — and it never says for `v2.`. */ private const val DEFAULT_ROLE = "member" @@ -115,12 +127,13 @@ object BuzzInviteLink { if (payloadB64 == code || payloadB64.isEmpty()) return null // `v2.` carries no client-readable payload — the community, role and expiry live - // only on the relay, which resolves the code on claim and returns them. There is nothing - // to decode and nothing to lose by admitting it: the join flow needs the host (for the - // relay url and the REST base) and the code, both of which the url itself carries, and the - // claim response supplies the rest. Matched on the literal prefix rather than by relaxing - // the decode below, so `…/invite/anything.else` still fails to parse. + // only on the relay, which resolves the code on claim and returns them. The join flow + // needs the host (for the relay url and the REST base) and the code, both of which the url + // itself carries, and the claim response supplies the rest. The relay routes on this exact + // prefix and never falls back to the v1 verifier, so a malformed `v2.` code is rejected + // here too rather than handed to a claim that can only answer `invite_invalid`. if (payloadB64 == V2_PREFIX) { + if (!isValidV2Code(code)) return null return BuzzInvite(host = host, code = code, communityId = "", role = DEFAULT_ROLE, expiresAt = null) } @@ -142,6 +155,24 @@ object BuzzInviteLink { ) } + /** + * True when [code] is exactly `v2.` followed by the unpadded base64url encoding of a 32-byte + * secret, in its canonical form — mirroring `validate_v2_code` in `buzz-core/src/invite.rs`, + * whose decode/re-encode comparison rejects padded or otherwise aliased encodings. + */ + @OptIn(ExperimentalEncodingApi::class) + fun isValidV2Code(code: String): Boolean { + if (!code.startsWith(V2_CODE_PREFIX)) return false + val encoded = code.substring(V2_CODE_PREFIX.length) + val secret = + try { + B64_NO_PAD.decode(encoded) + } catch (_: IllegalArgumentException) { + return false + } + return secret.size == V2_SECRET_LEN && B64_NO_PAD.encode(secret) == encoded + } + /** The host between the scheme's `//` and the `/invite/` marker, or null when absent. */ private fun extractHost( url: String, @@ -159,3 +190,49 @@ object BuzzInviteLink { return if (remainder == 0) s else s + "=".repeat(4 - remainder) } } + +/** + * The outcome vocabulary of Buzz's invite claim endpoint (`POST /api/invites/claim` in + * `buzz-relay/src/api/invites.rs`). A success answers 200 with a [STATUS_JOINED] or + * [STATUS_ALREADY_MEMBER] `status`; a refusal answers 403 with `{"error": }`. + */ +object BuzzInviteClaim { + /** 200: the claimer was added to the workspace. */ + const val STATUS_JOINED = "joined" + + /** 200: the claimer was already a member — nothing changed, and no use was consumed. */ + const val STATUS_ALREADY_MEMBER = "already_member" + + /** 403: the invite's lifetime has passed. */ + const val ERROR_EXPIRED = "invite_expired" + + /** 403: a `v2.` invite minted with `max_uses` has no uses left. */ + const val ERROR_EXHAUSTED = "invite_exhausted" + + /** 403: unknown, revoked, malformed, or minted for another workspace. */ + const val ERROR_INVALID = "invite_invalid" + + /** 403: the workspace has a join policy and no valid acceptance receipt was sent. */ + const val ERROR_JOIN_POLICY_REQUIRED = "join_policy_required" + + private val JSON = Json { ignoreUnknownKeys = true } + + @Serializable + private data class Body( + val status: String? = null, + val error: String? = null, + ) + + /** The `status` of a claim response body, or null when absent/unparseable. */ + fun statusOf(body: String): String? = parse(body)?.status + + /** The `error` slug of a claim response body, or null when absent/unparseable. */ + fun errorOf(body: String): String? = parse(body)?.error + + private fun parse(body: String): Body? = + try { + JSON.decodeFromString(body) + } catch (_: Exception) { + null + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/mpProjects/ProjectEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/mpProjects/ProjectEvent.kt new file mode 100644 index 0000000000..25f2fe34a0 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/mpProjects/ProjectEvent.kt @@ -0,0 +1,134 @@ +/* + * 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.buzz.mpProjects + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.buzz.mpProjects.tags.ProjectMember +import com.vitorpamplona.quartz.buzz.mpProjects.tags.ProjectVisibility +import com.vitorpamplona.quartz.nip01Core.core.Address +import com.vitorpamplona.quartz.nip01Core.core.BaseAddressableEvent +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate +import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate +import com.vitorpamplona.quartz.nip01Core.tags.dTag.dTag +import com.vitorpamplona.quartz.utils.TimeUtils +import kotlin.uuid.ExperimentalUuidApi +import kotlin.uuid.Uuid + +/** + * A Buzz NIP-MP project (`kind:30621`): an addressable, signed, named grouping of NIP-34 + * repository announcements (`kind:30617`, + * [com.vitorpamplona.quartz.nip34Git.repository.GitRepositoryEvent]) referenced by + * coordinate, so one project can span repositories owned by different pubkeys. It is metadata + * only — its signer gains no authority over any member. + * + * Tags: exactly one non-empty `d` (the project slug), optional `name` (≤256 bytes; clients + * fall back to `d`), optional `description` (≤2048 bytes), 0–64 member `a` tags + * ([ProjectMember]; no two with the same coordinate), optional `buzz-channel` and + * `buzz-visibility` (`listed`|`unlisted`, anything else reads as listed). `content` is `""` + * and carries no meaning; unknown tags are ignored. Global-only: a stray `h` never scopes it. + * + * Ground truth: `build_project` / `validate_project_envelope` in Buzz's + * `buzz-sdk/src/builders.rs`, `validate_project_envelope` in + * `buzz-relay/src/handlers/ingest.rs` (mirrored by [ProjectValidator]), and + * `docs/nips/NIP-MP.md` with its `NIP-MP.fixtures.json` oracle. + */ +@Immutable +class ProjectEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig) { + /** The project slug — the `d` tag. */ + fun slug() = dTag() + + /** The `name` tag, if any. */ + fun name() = tags.projectName() + + /** What clients display: the `name`, falling back to the slug. */ + fun displayName() = name() ?: slug() + + fun description() = tags.projectDescription() + + /** The well-formed member repositories. */ + fun members(): List = tags.projectMembers() + + /** The member repositories' `30617` addresses. */ + fun memberAddresses(): List
= members().map { it.address } + + /** The discussion channel reference — metadata, not routing. */ + fun channelId() = tags.projectChannel() + + fun visibility(): ProjectVisibility = tags.projectVisibility() + + fun isListed() = visibility() == ProjectVisibility.LISTED + + /** The relay's ingest verdict: null when accepted, else the rule that rejects it. */ + fun validate(): ProjectRejection? = ProjectValidator.validate(tags) + + fun isWellFormed() = validate() == null + + companion object { + const val KIND = 30621 + + /** + * Builds a project with Buzz's writer policy (`build_project`): a non-empty slug of at + * most 1024 bytes, [channelId] a UUID, empty content, and the whole envelope checked + * by [ProjectValidator] (so duplicate or oversized members and metadata are refused + * here instead of by the relay). + */ + @OptIn(ExperimentalUuidApi::class) + fun build( + slug: String, + name: String? = null, + description: String? = null, + members: List = emptyList(), + channelId: String? = null, + visibility: ProjectVisibility? = null, + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ): EventTemplate { + require(slug.isNotEmpty()) { "project slug must not be empty" } + require(slug.encodeToByteArray().size <= ProjectValidator.D_MAX_BYTES) { "project slug must not exceed ${ProjectValidator.D_MAX_BYTES} bytes" } + if (channelId != null) { + require(runCatching { Uuid.parse(channelId) }.isSuccess) { "buzz-channel must be a valid UUID (got $channelId)" } + } + + val template = + eventTemplate(KIND, "", createdAt) { + dTag(slug) + name?.let { projectName(it) } + description?.let { projectDescription(it) } + projectMembers(members) + channelId?.let { projectChannel(it) } + visibility?.let { projectVisibility(it) } + initializer() + } + + ProjectValidator.validate(template.tags)?.let { throw IllegalArgumentException("[${it.rule.id}] ${it.message}") } + return template + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/mpProjects/ProjectValidator.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/mpProjects/ProjectValidator.kt new file mode 100644 index 0000000000..0113a43219 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/mpProjects/ProjectValidator.kt @@ -0,0 +1,140 @@ +/* + * 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.buzz.mpProjects + +import com.vitorpamplona.quartz.buzz.mpProjects.tags.ChannelTag +import com.vitorpamplona.quartz.buzz.mpProjects.tags.ProjectMemberTag +import com.vitorpamplona.quartz.buzz.mpProjects.tags.VisibilityTag +import com.vitorpamplona.quartz.nip01Core.core.TagArray +import com.vitorpamplona.quartz.nip01Core.tags.dTag.DTag +import com.vitorpamplona.quartz.nip34Git.repository.tags.DescriptionTag +import com.vitorpamplona.quartz.nip34Git.repository.tags.NameTag + +/** + * The eight NIP-MP ingest rules. [id] is the stable identifier the shared conformance + * fixtures (`docs/nips/NIP-MP.fixtures.json` in Buzz) name in `reject_rules`. + */ +enum class ProjectRule( + val id: String, +) { + D_CARDINALITY("d-cardinality"), + D_EMPTY("d-empty"), + MEMBER_CAP("member-cap"), + MEMBER_TAG_ARITY("member-tag-arity"), + MEMBER_COORDINATE_MALFORMED("member-coordinate-malformed"), + MEMBER_DUPLICATE("member-duplicate"), + METADATA_CARDINALITY("metadata-cardinality"), + METADATA_LENGTH("metadata-length"), +} + +/** A rejected project envelope: which [rule] fired and why. */ +data class ProjectRejection( + val rule: ProjectRule, + val message: String, +) + +/** + * NIP-MP envelope validation — a port of `validate_project_envelope` in Buzz's + * `buzz-relay/src/handlers/ingest.rs` (and the matching Layer-A validator in + * `buzz-sdk/src/builders.rs`), evaluated in the relay's order so the first failing rule is the + * one the relay would report. `content` is never inspected and unknown tags are ignored. + * Deliberately absent, as upstream: any membership authorization — referencing another + * owner's repository is legal and grants nothing. + */ +object ProjectValidator { + /** Maximum member `a` tags, counted over raw tags (duplicates included). */ + const val MEMBER_CAP = 64 + + /** The relay's generic `d` bound (`D_TAG_MAX_LEN`); the SDK reports it as `d-empty`. */ + const val D_MAX_BYTES = 1024 + const val NAME_MAX_BYTES = 256 + const val DESCRIPTION_MAX_BYTES = 2048 + const val METADATA_TAG_MAX_BYTES = 256 + + /** Metadata tags a project may carry at most once each, in the relay's check order. */ + private val SINGLETON_METADATA = + listOf( + NameTag.TAG_NAME to NAME_MAX_BYTES, + DescriptionTag.TAG_NAME to DESCRIPTION_MAX_BYTES, + ChannelTag.TAG_NAME to ChannelTag.MAX_BYTES, + VisibilityTag.TAG_NAME to VisibilityTag.MAX_BYTES, + ) + + /** Null when [tags] form a valid project envelope; otherwise the first rule that fails. */ + fun validate(tags: TagArray): ProjectRejection? { + val dValues = mutableListOf() + val members = mutableListOf>() + val singletonCounts = IntArray(SINGLETON_METADATA.size) + val singletonValues = arrayOfNulls(SINGLETON_METADATA.size) + + for (tag in tags) { + if (tag.isEmpty()) continue + val value = tag.getOrNull(1) ?: "" + when (val name = tag[0]) { + DTag.TAG_NAME -> dValues.add(value) + ProjectMemberTag.TAG_NAME -> members.add(tag) + else -> { + val index = SINGLETON_METADATA.indexOfFirst { it.first == name } + if (index >= 0) { + singletonCounts[index]++ + singletonValues[index] = value + } + } + } + } + + if (dValues.size != 1) return reject(ProjectRule.D_CARDINALITY, "project event must have exactly one `d` tag (got ${dValues.size})") + if (dValues[0].isEmpty()) return reject(ProjectRule.D_EMPTY, "project event `d` tag must not be empty") + if (dValues[0].utf8Size() > D_MAX_BYTES) return reject(ProjectRule.D_EMPTY, "project event `d` tag exceeds $D_MAX_BYTES bytes") + + if (members.size > MEMBER_CAP) return reject(ProjectRule.MEMBER_CAP, "project event must have at most $MEMBER_CAP member `a` tags (got ${members.size})") + for (member in members) { + if (member.size !in 2..3) return reject(ProjectRule.MEMBER_TAG_ARITY, "project event member `a` tag must have exactly 2 or 3 elements (got ${member.size})") + } + val seen = HashSet() + for (member in members) { + val coordinate = member[1] + if (!ProjectMemberTag.isValidCoordinate(coordinate)) { + return reject(ProjectRule.MEMBER_COORDINATE_MALFORMED, "project event member `a` tag must be `30617::` (got \"$coordinate\")") + } + if (!seen.add(coordinate)) return reject(ProjectRule.MEMBER_DUPLICATE, "project event has duplicate member coordinate \"$coordinate\"") + } + + for (i in SINGLETON_METADATA.indices) { + if (singletonCounts[i] > 1) { + return reject(ProjectRule.METADATA_CARDINALITY, "project event must have at most one `${SINGLETON_METADATA[i].first}` tag (got ${singletonCounts[i]})") + } + } + for (i in SINGLETON_METADATA.indices) { + val value = singletonValues[i] ?: continue + val (name, max) = SINGLETON_METADATA[i] + if (value.utf8Size() > max) return reject(ProjectRule.METADATA_LENGTH, "project event `$name` tag too long (${value.utf8Size()} bytes, max $max)") + } + return null + } + + private fun reject( + rule: ProjectRule, + message: String, + ) = ProjectRejection(rule, message) + + private fun String.utf8Size() = encodeToByteArray().size +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/mpProjects/TagArrayBuilderExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/mpProjects/TagArrayBuilderExt.kt new file mode 100644 index 0000000000..5d834b5b9f --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/mpProjects/TagArrayBuilderExt.kt @@ -0,0 +1,41 @@ +/* + * 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.buzz.mpProjects + +import com.vitorpamplona.quartz.buzz.mpProjects.tags.ChannelTag +import com.vitorpamplona.quartz.buzz.mpProjects.tags.ProjectMember +import com.vitorpamplona.quartz.buzz.mpProjects.tags.ProjectMemberTag +import com.vitorpamplona.quartz.buzz.mpProjects.tags.ProjectVisibility +import com.vitorpamplona.quartz.buzz.mpProjects.tags.VisibilityTag +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip34Git.repository.tags.DescriptionTag +import com.vitorpamplona.quartz.nip34Git.repository.tags.NameTag + +fun TagArrayBuilder.projectName(name: String) = addUnique(NameTag.assemble(name)) + +fun TagArrayBuilder.projectDescription(description: String) = addUnique(DescriptionTag.assemble(description)) + +/** One `a` tag per member. Duplicate coordinates are not merged — the relay rejects them. */ +fun TagArrayBuilder.projectMembers(members: List) = members.forEach { add(ProjectMemberTag.assemble(it)) } + +fun TagArrayBuilder.projectChannel(channelId: String) = addUnique(ChannelTag.assemble(channelId)) + +fun TagArrayBuilder.projectVisibility(visibility: ProjectVisibility) = addUnique(VisibilityTag.assemble(visibility)) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/mpProjects/TagArrayExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/mpProjects/TagArrayExt.kt new file mode 100644 index 0000000000..3817145a5d --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/mpProjects/TagArrayExt.kt @@ -0,0 +1,50 @@ +/* + * 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.buzz.mpProjects + +import com.vitorpamplona.quartz.buzz.mpProjects.tags.ChannelTag +import com.vitorpamplona.quartz.buzz.mpProjects.tags.ProjectMember +import com.vitorpamplona.quartz.buzz.mpProjects.tags.ProjectMemberTag +import com.vitorpamplona.quartz.buzz.mpProjects.tags.ProjectVisibility +import com.vitorpamplona.quartz.buzz.mpProjects.tags.VisibilityTag +import com.vitorpamplona.quartz.nip01Core.core.TagArray +import com.vitorpamplona.quartz.nip01Core.core.fastFirstNotNullOfOrNull +import com.vitorpamplona.quartz.nip01Core.core.fastMapNotNullDense +import com.vitorpamplona.quartz.nip34Git.repository.tags.DescriptionTag +import com.vitorpamplona.quartz.nip34Git.repository.tags.NameTag + +/** The display name — the `name` tag. */ +fun TagArray.projectName(): String? = fastFirstNotNullOfOrNull(NameTag::parse) + +/** The description — the `description` tag. */ +fun TagArray.projectDescription(): String? = fastFirstNotNullOfOrNull(DescriptionTag::parse) + +/** The member repositories whose `a` tags are well-formed (malformed ones are skipped). */ +fun TagArray.projectMembers(): List = fastMapNotNullDense(ProjectMemberTag::parse) + +/** The discussion channel reference — the `buzz-channel` tag. */ +fun TagArray.projectChannel(): String? = fastFirstNotNullOfOrNull(ChannelTag::parse) + +/** The raw `buzz-visibility` value, uninterpreted. */ +fun TagArray.projectVisibilityCode(): String? = fastFirstNotNullOfOrNull(VisibilityTag::parse) + +/** The interpreted visibility: [ProjectVisibility.UNLISTED] only for exactly `unlisted`. */ +fun TagArray.projectVisibility(): ProjectVisibility = ProjectVisibility.interpret(projectVisibilityCode()) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/mpProjects/tags/ChannelTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/mpProjects/tags/ChannelTag.kt new file mode 100644 index 0000000000..eb37af499d --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/mpProjects/tags/ChannelTag.kt @@ -0,0 +1,48 @@ +/* + * 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.buzz.mpProjects.tags + +import com.vitorpamplona.quartz.nip01Core.core.Tag +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.ensure + +/** + * The NIP-MP `buzz-channel` tag — the UUID of the channel a project's discussion lives in. + * Metadata only: it neither routes nor scopes the event (projects are global), and a value + * the viewer cannot resolve must render as "no channel link", never as a broken one. The + * relay bounds it to [MAX_BYTES] and does not interpret it; Buzz's writer requires a UUID. + * Ground truth: `build_project` in Buzz's `buzz-sdk/src/builders.rs`. + */ +object ChannelTag { + const val TAG_NAME = "buzz-channel" + const val MAX_BYTES = 256 + + fun match(tag: Tag) = tag.has(1) && tag[0] == TAG_NAME + + fun parse(tag: Tag): String? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + return tag[1] + } + + fun assemble(channelId: String) = arrayOf(TAG_NAME, channelId) +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/mpProjects/tags/ProjectMemberTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/mpProjects/tags/ProjectMemberTag.kt new file mode 100644 index 0000000000..2f4b019bad --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/mpProjects/tags/ProjectMemberTag.kt @@ -0,0 +1,106 @@ +/* + * 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.buzz.mpProjects.tags + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.core.Address +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.Tag +import com.vitorpamplona.quartz.nip34Git.repository.GitRepositoryEvent +import com.vitorpamplona.quartz.utils.arrayOfNotNull + +/** + * One member repository of a NIP-MP project: the NIP-34 repository announcement's + * [address] (`30617::`) and an optional, opaque, unauthenticated + * [relayHint]. Member identity is the coordinate alone — two tags naming one coordinate with + * different hints are duplicates. + */ +@Immutable +data class ProjectMember( + val address: Address, + val relayHint: String? = null, +) { + /** The canonical coordinate string, the identity members are compared by. */ + val coordinate: String get() = address.toValue() +} + +/** + * A NIP-MP member `a` tag: `["a", "30617::"]` or + * `["a", "30617::", ""]`. + * + * The coordinate grammar is stricter than a generic NIP-01 address: the kind is the literal + * `30617` (a repository *announcement*, never `30618` state), the owner is exactly 64 + * **lowercase** hex characters (`#a` matching is byte-exact, so an uppercase owner would be + * invisible to readers), and the repo `d` is non-empty and taken verbatim — the value splits + * on the first two colons only, so a repo `d` containing `:` stays addressable. The relay + * hint is never parsed or validated by content. Ground truth: + * `parse_project_member_coordinate` in Buzz's `buzz-relay/src/handlers/ingest.rs` and + * `ProjectMemberCoord` in `buzz-sdk/src/builders.rs`. + */ +object ProjectMemberTag { + const val TAG_NAME = "a" + const val MEMBER_KIND = GitRepositoryEvent.KIND + private const val MEMBER_KIND_SEGMENT = "30617" + + /** Parses a strict member coordinate, or null when it is malformed. */ + fun parseCoordinate(coordinate: String): Address? { + val parts = coordinate.split(':', limit = 3) + if (parts.size != 3) return null + val (kind, owner, repoD) = parts + if (kind != MEMBER_KIND_SEGMENT) return null + if (!isLowercaseHex64(owner)) return null + if (repoD.isEmpty()) return null + return Address(MEMBER_KIND, owner, repoD) + } + + fun isValidCoordinate(coordinate: String) = parseCoordinate(coordinate) != null + + /** + * Parses a member tag with the arity (two or three elements) and coordinate rules the + * relay enforces; null for anything else. + */ + fun parse(tag: Tag): ProjectMember? { + if (tag.size !in 2..3 || tag[0] != TAG_NAME) return null + val address = parseCoordinate(tag[1]) ?: return null + return ProjectMember(address, tag.getOrNull(2)) + } + + fun assemble( + coordinate: String, + relayHint: String? = null, + ) = arrayOfNotNull(TAG_NAME, coordinate, relayHint) + + fun assemble(member: ProjectMember) = assemble(member.coordinate, member.relayHint) + + fun assemble( + owner: HexKey, + repoD: String, + relayHint: String? = null, + ) = assemble("$MEMBER_KIND_SEGMENT:$owner:$repoD", relayHint) + + private fun isLowercaseHex64(value: String): Boolean { + if (value.length != 64) return false + for (c in value) { + if (c !in '0'..'9' && c !in 'a'..'f') return false + } + return true + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/mpProjects/tags/ProjectVisibility.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/mpProjects/tags/ProjectVisibility.kt new file mode 100644 index 0000000000..b66efa433f --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/mpProjects/tags/ProjectVisibility.kt @@ -0,0 +1,46 @@ +/* + * 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.buzz.mpProjects.tags + +/** + * A NIP-MP project's listing visibility. Absent or unrecognized values read as [LISTED]: a + * typo in a metadata field is not a privacy signal, so it must never hide a project. + */ +enum class ProjectVisibility( + val code: String, +) { + LISTED("listed"), + UNLISTED("unlisted"), + ; + + companion object { + /** The writer-side parse: only the two defined values. */ + fun parseStrict(code: String): ProjectVisibility? = + when (code) { + LISTED.code -> LISTED + UNLISTED.code -> UNLISTED + else -> null + } + + /** The reader-side interpretation: anything but `unlisted` is [LISTED]. */ + fun interpret(code: String?): ProjectVisibility = if (code == UNLISTED.code) UNLISTED else LISTED + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/mpProjects/tags/VisibilityTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/mpProjects/tags/VisibilityTag.kt new file mode 100644 index 0000000000..ded41b541a --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/mpProjects/tags/VisibilityTag.kt @@ -0,0 +1,45 @@ +/* + * 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.buzz.mpProjects.tags + +import com.vitorpamplona.quartz.nip01Core.core.Tag +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.ensure + +/** + * The NIP-MP `buzz-visibility` tag — `listed` (the default) or `unlisted`. The relay bounds + * it to [MAX_BYTES] and does not interpret it; readers apply [ProjectVisibility.interpret]. + */ +object VisibilityTag { + const val TAG_NAME = "buzz-visibility" + const val MAX_BYTES = 256 + + fun match(tag: Tag) = tag.has(1) && tag[0] == TAG_NAME + + /** The raw value, uninterpreted. */ + fun parse(tag: Tag): String? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + return tag[1] + } + + fun assemble(visibility: ProjectVisibility) = arrayOf(TAG_NAME, visibility.code) +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/oaOwnerAttestation/AttestationConditions.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/oaOwnerAttestation/AttestationConditions.kt index afa11d275a..6189252748 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/oaOwnerAttestation/AttestationConditions.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/oaOwnerAttestation/AttestationConditions.kt @@ -95,5 +95,83 @@ data class AttestationConditions( /** True when [conditions] is a syntactically valid canonical conditions string. */ fun isValid(conditions: String): Boolean = parse(conditions) != null + + /** + * True when an authentication event signed at [authCreatedAt] satisfies every time clause + * in [conditions]. Both operators are strict (equality satisfies neither) and every clause + * must pass, including repeated ones — so this walks the raw string rather than [parse], + * which keeps only the last clause of each kind. `kind=` is deliberately ignored: Buzz + * treats the credential as connection-wide at admission. Ground truth: + * `verify_auth_tag_for_auth_event` in `buzz-sdk/src/nip_oa.rs`, enforced by the relay on + * NIP-42 AUTH, NIP-98 HTTP, Git and media admission. + */ + fun timeBoundsAllow( + conditions: String, + authCreatedAt: Long, + ): Boolean { + if (conditions.isEmpty()) return true + for (clause in conditions.split("&")) { + when { + clause.startsWith("created_at<") -> { + val bound = clause.removePrefix("created_at<").toLongOrNull() ?: return false + if (authCreatedAt >= bound) return false + } + clause.startsWith("created_at>") -> { + val bound = clause.removePrefix("created_at>").toLongOrNull() ?: return false + if (authCreatedAt <= bound) return false + } + } + } + return true + } + + /** + * True when every clause in [conditions] applies to an event of [kind] signed at + * [createdAt]: `kind=` must match and both time bounds hold (strictly). Unlike + * [timeBoundsAllow], `kind=` counts - this is the check for an attestation carried on an + * event (e.g. an agent's kind-0 profile), not for connection admission. Any clause it + * does not recognize fails. Mirrors `profile_valid_oa_owner_pubkey` in Buzz's + * `desktop/src-tauri/src/nostr_convert.rs`. + */ + fun appliesToEvent( + conditions: String, + kind: Int, + createdAt: Long, + ): Boolean { + if (conditions.isEmpty()) return true + for (clause in conditions.split("&")) { + val ok = + when { + clause.startsWith("kind=") -> clause.removePrefix("kind=").toIntOrNull() == kind + clause.startsWith("created_at<") -> clause.removePrefix("created_at<").toLongOrNull()?.let { createdAt < it } ?: false + clause.startsWith("created_at>") -> clause.removePrefix("created_at>").toLongOrNull()?.let { createdAt > it } ?: false + else -> false + } + if (!ok) return false + } + return true + } + + /** + * The last second at which [conditions] still admit an authentication (the tightest + * `created_at<` bound minus one), or null when there is no upper bound. + */ + fun validUntil(conditions: String): Long? = + conditions + .split("&") + .mapNotNull { if (it.startsWith("created_at<")) it.removePrefix("created_at<").toLongOrNull() else null } + .minOrNull() + ?.let { it - 1 } + + /** + * The first second at which [conditions] admit an authentication (the tightest + * `created_at>` bound plus one), or null when there is no lower bound. + */ + fun validFrom(conditions: String): Long? = + conditions + .split("&") + .mapNotNull { if (it.startsWith("created_at>")) it.removePrefix("created_at>").toLongOrNull() else null } + .maxOrNull() + ?.let { it + 1 } } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/oaOwnerAttestation/OwnerAttestation.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/oaOwnerAttestation/OwnerAttestation.kt index 1142eabb2c..e4be21aa4c 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/oaOwnerAttestation/OwnerAttestation.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/oaOwnerAttestation/OwnerAttestation.kt @@ -28,7 +28,6 @@ import com.vitorpamplona.quartz.nip01Core.core.isValid import com.vitorpamplona.quartz.nip01Core.core.toHexKey import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair import com.vitorpamplona.quartz.nip01Core.crypto.Nip01Crypto -import com.vitorpamplona.quartz.utils.Hex import com.vitorpamplona.quartz.utils.sha256.sha256 /** @@ -65,26 +64,54 @@ data class OwnerAttestation( /** * Verifies this attestation authorizes [agentPubKey]. Checks structural validity - * (valid hex keys, canonical conditions, no self-attestation) and then the owner's - * Schnorr signature over the commitment hash. + * (lowercase hex keys and signature, canonical conditions, no self-attestation) and then + * the owner's Schnorr signature over the commitment hash. Time clauses are NOT evaluated + * here; see [verifyForAuthAt]. */ fun verify(agentPubKey: HexKey): Boolean { - if (!agentPubKey.isValid() || !ownerPubKey.isValid()) return false + if (!agentPubKey.isValid() || !ownerPubKey.isValid() || !isLowercaseHex(ownerPubKey, 64)) return false // Self-attestation is explicitly prohibited: the owner must differ from the agent. if (ownerPubKey == agentPubKey) return false if (!AttestationConditions.isValid(conditions)) return false - if (sig.length != 128 || !Hex.isHex(sig)) return false + // Buzz rejects uppercase hex before it reaches its (permissive) decoder. + if (!isLowercaseHex(sig, 128)) return false val message = sha256(commitment(agentPubKey).encodeToByteArray()) return Nip01Crypto.verify(sig.hexToByteArray(), message, ownerPubKey.hexToByteArray()) } + /** + * The check Buzz runs at admission: [verify], plus every `created_at<` / `created_at>` + * clause evaluated against the signed authentication event's [authCreatedAt] (strictly). + * An expired or not-yet-valid credential grants nothing, so the relay treats the agent as a + * non-member (`restricted`). + */ + fun verifyForAuthAt( + agentPubKey: HexKey, + authCreatedAt: Long, + ): Boolean = verify(agentPubKey) && isValidAt(authCreatedAt) + + /** True when the time clauses admit an authentication signed at [authCreatedAt]. */ + fun isValidAt(authCreatedAt: Long): Boolean = AttestationConditions.timeBoundsAllow(conditions, authCreatedAt) + + /** The last second this credential admits an authentication, or null when unbounded. */ + fun validUntil(): Long? = AttestationConditions.validUntil(conditions) + + /** The first second this credential admits an authentication, or null when unbounded. */ + fun validFrom(): Long? = AttestationConditions.validFrom(conditions) + /** Builds the NIP-OA `auth` tag for this attestation. */ fun toTag(): Array = AuthTag.assemble(this) companion object { const val COMMITMENT_PREFIX = "nostr:agent-auth:" + /** Exactly [length] characters of lowercase hex, the only form Buzz accepts on the wire. */ + fun isLowercaseHex( + value: String, + length: Int, + ): Boolean = value.length == length && value.all { it in '0'..'9' || it in 'a'..'f' } + /** SHA-256 of the commitment string — the 32-byte message the owner signs. */ fun commitmentHash( agentPubKey: HexKey, @@ -119,6 +146,35 @@ data class OwnerAttestation( return sign(agentPubKey, conditions.encode(), priv) } + /** + * The owner an agent's event declares through NIP-OA, or null. The event must carry + * **exactly one** `auth` tag (a malformed second one still counts, so there is no + * first-valid-tag fallback), it must verify for the event's own author [pubKey], and every + * condition must apply to this event ([AttestationConditions.appliesToEvent]). + * + * Buzz reads an agent's owner this way off the agent's kind-0 profile: that owner may edit + * the agent's channel messages, and names the agent in the UI. The event's own signature + * is the caller's to have checked. Ground truth: `profile_valid_oa_owner_pubkey` in Buzz's + * `desktop/src-tauri/src/nostr_convert.rs`. + */ + fun verifiedOwnerOf( + pubKey: HexKey, + kind: Int, + createdAt: Long, + tags: Array>, + ): HexKey? { + var authTag: Array? = null + for (tag in tags) { + if (tag.isEmpty() || tag[0] != AuthTag.TAG_NAME) continue + if (authTag != null) return null + authTag = tag + } + val attestation = AuthTag.parse(authTag ?: return null) ?: return null + if (!attestation.verify(pubKey)) return null + if (!AttestationConditions.appliesToEvent(attestation.conditions, kind, createdAt)) return null + return attestation.ownerPubKey + } + /** Parses a NIP-OA `auth` tag; see [AuthTag.parse]. Does not verify the signature. */ fun parse(tag: Tag): OwnerAttestation? = AuthTag.parse(tag) } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/oaOwnerAttestation/tags/AuthTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/oaOwnerAttestation/tags/AuthTag.kt index 4a48e676aa..668d053967 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/oaOwnerAttestation/tags/AuthTag.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/oaOwnerAttestation/tags/AuthTag.kt @@ -24,7 +24,6 @@ import com.vitorpamplona.quartz.buzz.oaOwnerAttestation.AttestationConditions import com.vitorpamplona.quartz.buzz.oaOwnerAttestation.OwnerAttestation import com.vitorpamplona.quartz.nip01Core.core.Tag import com.vitorpamplona.quartz.nip01Core.core.has -import com.vitorpamplona.quartz.utils.Hex import com.vitorpamplona.quartz.utils.ensure /** @@ -43,21 +42,22 @@ object AuthTag { /** * Parses a well-formed `auth` tag into an [OwnerAttestation]. Returns null on any - * structural problem: wrong name/arity, an owner pubkey that is not 64-char hex, a - * signature that is not 128-char hex, or non-canonical conditions. This is a + * structural problem: wrong name or not exactly 4 elements, an owner pubkey that is not + * 64-char lowercase hex, a signature that is not 128-char lowercase hex, or non-canonical + * conditions (mirrors `parse_auth_tag_fields` in Buzz's `buzz-sdk/src/nip_oa.rs`). This is a * *shape* check only — it does not verify the signature (call * [OwnerAttestation.verify]). */ fun parse(tag: Array): OwnerAttestation? { - ensure(tag.has(3)) { return null } + ensure(tag.size == 4) { return null } ensure(tag[0] == TAG_NAME) { return null } val owner = tag[1] val conditions = tag[2] val sig = tag[3] - ensure(Hex.isHex64(owner)) { return null } - ensure(sig.length == 128 && Hex.isHex(sig)) { return null } + ensure(OwnerAttestation.isLowercaseHex(owner, 64)) { return null } + ensure(OwnerAttestation.isLowercaseHex(sig, 128)) { return null } ensure(AttestationConditions.isValid(conditions)) { return null } return OwnerAttestation(owner, conditions, sig) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/relay/BuzzMembershipPolicy.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/relay/BuzzMembershipPolicy.kt index e2a1ee4236..5211806a95 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/relay/BuzzMembershipPolicy.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/relay/BuzzMembershipPolicy.kt @@ -67,13 +67,15 @@ open class BuzzMembershipPolicy( /** * Runs after the NIP-42 proof checks out (see [FullAuthPolicy.authorize]). If the auth event * carries an owner-signed attestation authorizing this agent, and the owner is a member, - * remember the agent as a member for this connection. We never throw here — a missing or - * invalid attestation just means the key is authenticated but not (yet) authorized, which the - * membership gate below handles. + * remember the agent as a member for this connection. Like Buzz, the credential's + * `created_at<` / `created_at>` clauses are checked against the AUTH event's own `created_at`, + * so an expired or not-yet-valid attestation grants nothing. We never throw here — a missing + * or invalid attestation just means the key is authenticated but not (yet) authorized, which + * the membership gate below handles. */ override suspend fun authorize(event: RelayAuthEvent) { val attestation = event.tags.firstNotNullOfOrNull(AuthTag::parse) ?: return - if (attestation.ownerPubKey in members && attestation.verify(event.pubKey)) { + if (attestation.ownerPubKey in members && attestation.verifyForAuthAt(event.pubKey, event.createdAt)) { authorizedAgents.add(event.pubKey) } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/rsReadState/ReadState.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/rsReadState/ReadState.kt index c68ae5daec..9e6a5cd047 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/rsReadState/ReadState.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/rsReadState/ReadState.kt @@ -22,10 +22,12 @@ package com.vitorpamplona.quartz.buzz.rsReadState import com.vitorpamplona.quartz.nip01Core.core.TagArray import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.core.toHexKey import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner import com.vitorpamplona.quartz.nip01Core.tags.dTag.dTag import com.vitorpamplona.quartz.nip01Core.tags.hashtags.HashtagTag import com.vitorpamplona.quartz.nip78AppData.AppSpecificDataEvent +import com.vitorpamplona.quartz.utils.RandomInstance import com.vitorpamplona.quartz.utils.TimeUtils /** @@ -36,7 +38,9 @@ import com.vitorpamplona.quartz.utils.TimeUtils * `kind:30078` [com.vitorpamplona.quartz.nip01Core.core.Event] subclass — a second class on the * same kind would break [com.vitorpamplona.quartz.utils.EventFactory] dispatch. Instead it is a * thin content/tag layer *on top of* [AppSpecificDataEvent]: - * - the `d` tag is `read-state:` ([dTagFor] / [slotIdFrom]), + * - the `d` tag is `read-state:` ([dTagFor] / [slotIdFrom]), where `` is exactly + * 32 lowercase hex characters ([isValidSlotId]) — any other shape MUST be ignored, since the + * fixed shape is what lets a relay recognize a read-state coordinate structurally, * - a single `["t", "read-state"]` tag enables relay-side filtering, * - `content` is a NIP-44 self-encrypted [ReadStateContent] (conversation key * `nip44(user_privkey, user_pubkey)` — the user's own key on both sides). @@ -47,18 +51,36 @@ object ReadState { const val D_TAG_PREFIX = "read-state:" const val T_TAG_VALUE = "read-state" + /** A `` is exactly this many lowercase hex characters (16 random bytes). */ + const val SLOT_ID_LENGTH = 32 + + /** True when [slotId] is exactly 32 lowercase hex characters (`[0-9a-f]{32}`). */ + fun isValidSlotId(slotId: String): Boolean = slotId.length == SLOT_ID_LENGTH && slotId.all { it in '0'..'9' || it in 'a'..'f' } + + /** A fresh random ``: 16 secure random bytes as lowercase hex. */ + fun newSlotId(): String = RandomInstance.bytes(SLOT_ID_LENGTH / 2).toHexKey() + /** The addressable `d`-tag value for a given slot id. */ fun dTagFor(slotId: String): String = "$D_TAG_PREFIX$slotId" - /** Extracts the `` from a `read-state:` d-tag value, or `null` if it does not match. */ - fun slotIdFrom(dTagValue: String): String? = dTagValue.removePrefix(D_TAG_PREFIX).takeIf { it != dTagValue && it.isNotEmpty() } + /** + * Extracts the `` from a `read-state:` d-tag value, or `null` if it does not + * match — including a slot id that is not exactly 32 lowercase hex characters. + */ + fun slotIdFrom(dTagValue: String): String? { + if (!dTagValue.startsWith(D_TAG_PREFIX)) return null + return dTagValue.substring(D_TAG_PREFIX.length).takeIf(::isValidSlotId) + } - /** True if [tags] are a well-formed NIP-RS coordinate: exactly one `read-state:` d-tag and one `["t","read-state"]`. */ + /** + * True if [tags] are a well-formed NIP-RS coordinate: exactly one `d` tag, holding + * `read-state:<32 lowercase hex>`, and exactly one `["t","read-state"]`. + */ fun isReadState(tags: TagArray): Boolean { val dTags = tags.count { it.size > 1 && it[0] == "d" } val readStateT = tags.count { it.size > 1 && it[0] == HashtagTag.TAG_NAME && it[1] == T_TAG_VALUE } val dValue = tags.firstOrNull { it.size > 1 && it[0] == "d" }?.getOrNull(1) - return dTags == 1 && readStateT == 1 && dValue != null && dValue.startsWith(D_TAG_PREFIX) + return dTags == 1 && readStateT == 1 && dValue != null && slotIdFrom(dValue) != null } /** Adds the NIP-RS `["t", "read-state"]` filter tag to an [AppSpecificDataEvent] builder. */ @@ -74,6 +96,7 @@ object ReadState { signer: NostrSigner, createdAt: Long = TimeUtils.now(), ): AppSpecificDataEvent { + require(isValidSlotId(slotId)) { "NIP-RS slot id must be 32 lowercase hex characters" } val ciphertext = signer.nip44Encrypt(content.encodeToJson(), signer.pubKey) return signer.sign( AppSpecificDataEvent.build(dTagFor(slotId), ciphertext, createdAt) { diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/rsReadState/ReadStateContent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/rsReadState/ReadStateContent.kt index fe2662d1cc..cbf27d3a0b 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/rsReadState/ReadStateContent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/rsReadState/ReadStateContent.kt @@ -22,9 +22,11 @@ package com.vitorpamplona.quartz.buzz.rsReadState import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable -import kotlinx.serialization.decodeFromString import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive /** * The decrypted NIP-RS (Buzz cross-device read-state, `docs/nips/NIP-RS.md`) blob: a per-client @@ -34,6 +36,18 @@ import kotlinx.serialization.json.Json * blobs with an unknown [v]; [contexts] values are "all messages in this context at or before * this time are read." This is the plaintext that gets NIP-44 self-encrypted into an * [com.vitorpamplona.quartz.nip78AppData.AppSpecificDataEvent] `content` — see [ReadState]. + * + * [contexts] is the **wire** map. It mixes two kinds of entry: + * - frontier entries, keyed by a context id escaped with [ReadStateKeys.escape] (an id that + * starts with the reserved `ov_` / `esc:` gets one `esc:` prepended) — read them with [frontiers]; + * - the manual-unread override layer's `ov_s:` / `ov_c:` / `ov_b:` counters — read them with + * [overrides], build them with [build]. + * + * [decodeFromJson] applies NIP-RS "Content Validation": the blob is rejected as a whole only for a + * structural fault (not an object, missing/non-integer `v`, missing `client_id` or `contexts`, more + * than [MAX_CONTEXT_ENTRIES] entries); a bad individual entry (a value outside `0..2^32-1`, a key + * over [MAX_KEY_BYTES] bytes) is dropped and the rest is kept, and override counters are validated + * as a complete per-context group before anything else touches them. */ @Serializable data class ReadStateContent( @@ -46,9 +60,48 @@ data class ReadStateContent( /** NIP-RS validity: the schema version is the one this client understands and `client_id` is 1–64 chars. */ fun isSupported(): Boolean = v == CURRENT_VERSION && clientId.length in 1..64 + /** + * The read frontiers keyed by **raw** context id: escaped wire keys are unescaped, and the + * reserved `ov_` override entries are left out. + */ + fun frontiers(): Map { + val result = LinkedHashMap() + contexts.forEach { (key, value) -> + if (!ReadStateKeys.isReservedWireKey(key)) result[ReadStateKeys.unescape(key)] = value + } + return result + } + + /** + * The manual-unread override registers keyed by raw context id. Every group that survived + * [decodeFromJson] is complete — a live `ov_s`/`ov_c`/`ov_b` triple or a lone `ov_c` tombstone + * floor — and every group has an `ov_c`, so that key anchors the lookup. + */ + fun overrides(): Map { + val result = LinkedHashMap() + contexts.forEach { (key, clear) -> + if (key.startsWith(ReadStateKeys.OV_CLEAR)) { + val ctx = key.substring(ReadStateKeys.OV_CLEAR.length) + val set = contexts[ReadStateKeys.OV_SET + ctx] ?: 0L + val baseline = contexts[ReadStateKeys.OV_BASELINE + ctx] ?: 0L + result[ctx] = OverrideRegister(set, clear, baseline) + } + } + return result + } + companion object { const val CURRENT_VERSION = 1 + /** A blob with more context entries than this is rejected outright. */ + const val MAX_CONTEXT_ENTRIES = 10_000 + + /** A context key longer than this many UTF-8 bytes is dropped (for an `ov_` key: its whole group). */ + const val MAX_KEY_BYTES = 256 + + /** The largest timestamp / counter value: `2^32 - 1`. */ + const val MAX_VALUE = 4_294_967_295L + val JSON = Json { ignoreUnknownKeys = true @@ -56,6 +109,96 @@ data class ReadStateContent( encodeDefaults = true } - fun decodeFromJson(json: String): ReadStateContent = JSON.decodeFromString(json) + /** + * Parses and validates a decrypted NIP-RS blob. Throws [IllegalArgumentException] (or a + * serialization exception for malformed JSON) when the whole blob must be discarded. A blob + * with an unknown [v] parses with empty [contexts] and fails [isSupported] — NIP-RS says to + * ignore it, and its `contexts` may not even follow this schema. + */ + fun decodeFromJson(json: String): ReadStateContent { + val root = JSON.parseToJsonElement(json) as? JsonObject ?: throw IllegalArgumentException("NIP-RS blob is not a JSON object") + + val v = (root["v"] as? JsonPrimitive)?.takeIf { !it.isString }?.content?.toIntOrNull() + requireNotNull(v) { "NIP-RS blob has a missing or non-integer v" } + + val clientId = (root["client_id"] as? JsonPrimitive)?.takeIf { it.isString }?.content + requireNotNull(clientId) { "NIP-RS blob has no client_id string" } + + if (v != CURRENT_VERSION) return ReadStateContent(v, clientId, emptyMap()) + + val contexts = root["contexts"] as? JsonObject ?: throw IllegalArgumentException("NIP-RS blob has no contexts object") + require(contexts.size <= MAX_CONTEXT_ENTRIES) { "NIP-RS blob has more than $MAX_CONTEXT_ENTRIES context entries" } + + return ReadStateContent(v, clientId, sanitizeContexts(contexts)) + } + + /** + * Builds a blob from raw context ids: [frontiers] are written under their escaped keys, and + * each of [overrides] is canonicalized against its context's frontier first (NIP-RS + * "Mandatory Canonical Publication") — a live register as its three keys, a dead one as a + * lone `ov_c` tombstone floor, a virgin one not at all. + */ + fun build( + clientId: String, + frontiers: Map, + overrides: Map = emptyMap(), + ): ReadStateContent { + val contexts = LinkedHashMap() + frontiers.forEach { (ctx, ts) -> contexts[ReadStateKeys.escape(ctx)] = ts } + overrides.forEach { (ctx, register) -> + register.canonicalize(frontiers[ctx] ?: 0L)?.let { canonical -> + if (canonical.isTombstone()) { + contexts[ReadStateKeys.OV_CLEAR + ctx] = canonical.clear + } else { + contexts[ReadStateKeys.OV_SET + ctx] = canonical.set + contexts[ReadStateKeys.OV_CLEAR + ctx] = canonical.clear + contexts[ReadStateKeys.OV_BASELINE + ctx] = canonical.baseline + } + } + } + return ReadStateContent(CURRENT_VERSION, clientId, contexts) + } + + private fun sanitizeContexts(contexts: JsonObject): Map { + val result = LinkedHashMap() + // Override counters are validated as a group BEFORE any per-entry rule: NIP-RS forbids + // dropping one bad sibling and keeping the rest (a partial group rebuilds a wrong register). + val groups = LinkedHashMap>() + + contexts.forEach { (key, value) -> + val prefix = ReadStateKeys.overridePrefixOf(key) + if (prefix != null) { + groups.getOrPut(key.substring(prefix.length)) { LinkedHashMap() }[prefix] = value + } else { + val ts = value.asUInt32() + if (ts != null && key.encodeToByteArray().size <= MAX_KEY_BYTES) result[key] = ts + } + } + + groups.forEach { (ctx, members) -> + val isLive = members.size == 3 + val isTombstone = members.size == 1 && ReadStateKeys.OV_CLEAR in members + if (!isLive && !isTombstone) return@forEach + + val values = LinkedHashMap() + members.forEach { (prefix, value) -> + val key = prefix + ctx + val counter = value.asUInt32() ?: return@forEach + if (key.encodeToByteArray().size > MAX_KEY_BYTES) return@forEach + values[key] = counter + } + // One invalid sibling rejects the whole group (its frontier entry is kept). + if (values.size == members.size) result.putAll(values) + } + return result + } + + /** An integer JSON number in `0..2^32-1`, or null (a string, a fraction, out of range, null…). */ + private fun JsonElement.asUInt32(): Long? { + val primitive = this as? JsonPrimitive ?: return null + if (primitive.isString) return null + val value = primitive.content.toLongOrNull() ?: return null + return value.takeIf { it in 0..MAX_VALUE } + } } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/rsReadState/ReadStateOverrides.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/rsReadState/ReadStateOverrides.kt new file mode 100644 index 0000000000..e1f025a1d5 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/rsReadState/ReadStateOverrides.kt @@ -0,0 +1,171 @@ +/* + * 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.buzz.rsReadState + +/** + * NIP-RS "Reserved Namespace": the key vocabulary of a read-state `contexts` map. + * + * The `ov_` stem belongs to the manual-unread override layer and `esc:` is its escape marker, so a + * raw context id starting with either is written with one extra leading `esc:` ([escape]) and read + * back by stripping exactly one ([unescape]) — a bijection. The `ov_s:`/`ov_c:`/`ov_b:` keys carry + * the **unescaped** raw context id as their suffix. + */ +object ReadStateKeys { + const val RESERVED_STEM = "ov_" + const val ESCAPE = "esc:" + + /** Set counter S of a context's override register. */ + const val OV_SET = "ov_s:" + + /** Clear counter C of a context's override register (also the lone key of a tombstone floor). */ + const val OV_CLEAR = "ov_c:" + + /** Baseline B: the effective frontier when the context was last marked unread. */ + const val OV_BASELINE = "ov_b:" + + /** The frontier wire key for raw context id [ctx]. */ + fun escape(ctx: String): String = if (ctx.startsWith(RESERVED_STEM) || ctx.startsWith(ESCAPE)) ESCAPE + ctx else ctx + + /** The raw context id behind frontier wire key [wireKey]: strips exactly one leading `esc:`. */ + fun unescape(wireKey: String): String = if (wireKey.startsWith(ESCAPE)) wireKey.substring(ESCAPE.length) else wireKey + + /** True for a wire key in the reserved `ov_` namespace — never a frontier entry. */ + fun isReservedWireKey(wireKey: String): Boolean = wireKey.startsWith(RESERVED_STEM) + + /** The override prefix ([OV_SET], [OV_CLEAR] or [OV_BASELINE]) [wireKey] starts with, or null. */ + fun overridePrefixOf(wireKey: String): String? = + when { + wireKey.startsWith(OV_SET) -> OV_SET + wireKey.startsWith(OV_CLEAR) -> OV_CLEAR + wireKey.startsWith(OV_BASELINE) -> OV_BASELINE + else -> null + } +} + +/** + * A NIP-RS manual-unread override register `(S, C, B)` for one context: [set] counts mark-unread + * actions, [clear] counts explicit mark-reads, and [baseline] is the effective frontier captured at + * the most recent mark-unread. Registers merge by componentwise max ([merge]); the override is live + * only while `S > 0 && F <= B && S > C` ([isActive]) — clear wins ties. + * + * Every value is a uint32 (`0..2^32-1`); a counter that would pass that is refused, never wrapped. + */ +data class OverrideRegister( + val set: Long, + val clear: Long, + val baseline: Long, +) { + init { + require(set in 0..ReadStateContent.MAX_VALUE && clear in 0..ReadStateContent.MAX_VALUE && baseline in 0..ReadStateContent.MAX_VALUE) { + "override counters must be uint32" + } + } + + /** Never activated: publishes nothing. */ + fun isVirgin(): Boolean = set == 0L && clear == 0L + + /** The tombstone-floor shape `(0, C, 0)` with `C > 0`: published as a lone `ov_c` key. */ + fun isTombstone(): Boolean = set == 0L && baseline == 0L && clear > 0L + + /** The liveness predicate against the merged effective [frontier]. */ + fun isActive(frontier: Long): Boolean = set > 0 && frontier <= baseline && set > clear + + /** Componentwise max — the only merge rule. */ + fun merge(other: OverrideRegister): OverrideRegister = OverrideRegister(maxOf(set, other.set), maxOf(clear, other.clear), maxOf(baseline, other.baseline)) + + /** + * The register as it must be published against [frontier]: unchanged while live, compacted to + * the tombstone floor `(0, max(S, C), 0)` once dead, and null (omit it) when virgin. + */ + fun canonicalize(frontier: Long): OverrideRegister? = + when { + isVirgin() -> null + isActive(frontier) -> this + else -> OverrideRegister(0, maxOf(set, clear), 0) + } + + /** + * Mark-unread at the current effective [frontier]: `S = max(S, C) + 1`, `B = frontier`. Null when + * `max(S, C)` is already the uint32 maximum — the action must then be refused. + */ + fun markUnread(frontier: Long): OverrideRegister? { + val top = maxOf(set, clear) + if (top >= ReadStateContent.MAX_VALUE) return null + return OverrideRegister(top + 1, clear, frontier) + } + + /** + * Explicit mark-read, evaluated against the frontier *after* the caller advanced it + * ([frontierAfter]): `C = max(S, C) + 1`. At the uint32 ceiling the counters stay as they are and + * the action succeeds only if the override is already inactive; null means it must be reported + * as failed. + */ + fun markRead(frontierAfter: Long): OverrideRegister? { + val top = maxOf(set, clear) + if (top < ReadStateContent.MAX_VALUE) return OverrideRegister(set, top + 1, baseline) + return if (isActive(frontierAfter)) null else this + } + + companion object { + val VIRGIN = OverrideRegister(0, 0, 0) + } +} + +/** + * The effective read state across every supported blob a user published: frontiers and override + * registers each merged by max. [isUnread] is the NIP-RS verdict + * `latest_message_ts > F || override_active(S, C, B, F)`. + */ +class MergedReadState( + val frontiers: Map, + val overrides: Map, +) { + /** The merged frontier of raw context [ctx], or 0 when none was published. */ + fun frontier(ctx: String): Long = frontiers[ctx] ?: 0L + + /** True while [ctx] carries a live manual-unread override. */ + fun isOverrideActive(ctx: String): Boolean = overrides[ctx]?.isActive(frontier(ctx)) == true + + /** Whether [ctx] reads as unread given the `created_at` of its newest message. */ + fun isUnread( + ctx: String, + latestMessageTs: Long, + ): Boolean = latestMessageTs > frontier(ctx) || isOverrideActive(ctx) + + companion object { + /** Merges [blobs], skipping any that fail [ReadStateContent.isSupported]. */ + fun merge(blobs: List): MergedReadState { + val frontiers = LinkedHashMap() + val overrides = LinkedHashMap() + blobs.forEach { blob -> + if (!blob.isSupported()) return@forEach + blob.frontiers().forEach { (ctx, ts) -> + val current = frontiers[ctx] + if (current == null || ts > current) frontiers[ctx] = ts + } + blob.overrides().forEach { (ctx, register) -> + overrides[ctx] = overrides[ctx]?.merge(register) ?: register + } + } + return MergedReadState(frontiers, overrides) + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/stream/BuzzChatMessage.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/stream/BuzzChatMessage.kt new file mode 100644 index 0000000000..6308a134b2 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/stream/BuzzChatMessage.kt @@ -0,0 +1,63 @@ +/* + * 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.buzz.stream + +import com.vitorpamplona.quartz.buzz.threading.buzzThread +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip29RelayGroups.hTag +import com.vitorpamplona.quartz.nipC7Chats.ChatEvent +import com.vitorpamplona.quartz.utils.TimeUtils + +/** + * A Buzz channel message: a plain NIP-29 kind-9 [ChatEvent] in Buzz's tag shape. This is what every + * Buzz client writes today (desktop, mobile, CLI); kind 40002 ([StreamMessageV2Event]) is only a + * read-compat tail from the 10002 -> 40001 -> 40002 migration. + * + * Mirrors `build_message` in `buzz-sdk/src/builders.rs`, in its tag order: + * - `["h", channel]` + * - the NIP-10 thread markers when it is a reply ([buzzThread]: one `reply` marker for a direct + * reply, `root` + `reply` for a nested one) - derive [threadRoot] with + * `buzzThreadRootForReplyTo` on the parent or the relay rejects the ancestry + * - one `p` per mention (the author may mention themselves) + * - `["broadcast", "1"]` for a reply that should also show in the channel timeline instead of + * only in its thread + * + * Media (`imeta`) and NIP-30 `emoji` tags follow via [initializer]. + */ +object BuzzChatMessage { + fun build( + channelId: String, + content: String, + threadRoot: HexKey? = null, + replyTo: HexKey? = null, + mentions: List = emptyList(), + broadcast: Boolean = false, + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = ChatEvent.build(content, createdAt) { + hTag(channelId) + if (replyTo != null) buzzThread(threadRoot ?: replyTo, replyTo) + mentions(mentions) + if (broadcast) broadcast() + initializer() + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/stream/BuzzEditTagOverlay.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/stream/BuzzEditTagOverlay.kt new file mode 100644 index 0000000000..544a9fa1c3 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/stream/BuzzEditTagOverlay.kt @@ -0,0 +1,76 @@ +/* + * 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.buzz.stream + +import com.vitorpamplona.quartz.nip01Core.core.TagArray + +/** + * The tags a Buzz message renders with once a kind-40003 edit is applied: the original message's + * tags with the edit's attachments, custom emoji and added mentions overlaid. Buzz's clients + * resolve this on read (the relay stores the edit as-is), so this mirrors `applyEditTagOverlay` + * in Buzz's `desktop/src/features/messages/lib/applyEditTagOverlay.mjs`, in its output order: + * + * - `imeta` comes **only** from the edit. The edit re-sends the full attachment set, so an edit + * without any `imeta` means "no attachments". + * - `emoji` (NIP-30) comes from the edit when the edit has any, otherwise the original's stay: + * an older or foreign client's edit carries none, and dropping them would break a `:shortcode:` + * the original rendered. + * - `p` tags in the edit are added to the original's (only newly added mentions notify). + * - `mention` reference tags are replaced by the edit's when it carries `["buzz:mention-snapshot"]`, + * except the send-time `agent-address` ones, which always stay. + * - everything else (`h`, `e`, thread markers, `broadcast`, …) comes from the original only, so an + * edit can't move a message to another channel or thread. + */ +object BuzzEditTagOverlay { + const val IMETA = "imeta" + const val EMOJI = "emoji" + const val PUBKEY = "p" + const val MENTION = "mention" + const val MENTION_SNAPSHOT = "buzz:mention-snapshot" + const val AGENT_ADDRESS_MARKER = "agent-address" + + private fun isAgentAddressMention(tag: Array) = tag.size > 2 && tag[0] == MENTION && tag[2] == AGENT_ADDRESS_MARKER + + fun apply( + originalTags: TagArray, + editTags: TagArray?, + ): TagArray { + if (editTags == null) return originalTags + + val editEmoji = editTags.filter { it.isNotEmpty() && it[0] == EMOJI } + val hasMentionSnapshot = editTags.any { it.isNotEmpty() && it[0] == MENTION_SNAPSHOT } + val editMentions = editTags.filter { it.isNotEmpty() && it[0] == MENTION && !isAgentAddressMention(it) } + + val fromOriginal = + originalTags.filter { tag -> + if (tag.isEmpty()) return@filter true + when { + tag[0] == IMETA -> false + editEmoji.isNotEmpty() && tag[0] == EMOJI -> false + hasMentionSnapshot && tag[0] == MENTION && !isAgentAddressMention(tag) -> false + else -> true + } + } + val fromEdit = editTags.filter { it.isNotEmpty() && (it[0] == IMETA || it[0] == PUBKEY || it[0] == MENTION_SNAPSHOT) } + + return (fromOriginal + fromEdit + editEmoji + editMentions).toTypedArray() + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/stream/CanvasEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/stream/CanvasEvent.kt index 9275579285..573b7926c0 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/stream/CanvasEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/stream/CanvasEvent.kt @@ -21,6 +21,7 @@ package com.vitorpamplona.quartz.buzz.stream import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.buzz.stream.tags.ExpectedRevisionTag import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder @@ -33,7 +34,13 @@ import com.vitorpamplona.quartz.utils.TimeUtils * A Buzz channel canvas (shared markdown document), `kind:40100`. * * Mirrors `build_set_canvas` in Buzz's `buzz-sdk/src/builders.rs`: a single `h` - * channel tag with the markdown document in [content]. + * channel tag with the markdown document in [content], plus an optional + * `["expected-revision", …]` compare-and-swap precondition ([ExpectedRevisionTag]). + * + * The live canvas is the newest revision under `created_at DESC, id ASC` (a same-second tie goes + * to the smallest id — see [isNewerHeadThan]). A write that edits a loaded head stamps + * `created_at = max(now, head.created_at + 1)` ([writeCreatedAt]) so it always sorts strictly + * ahead of the head it asserts. */ @Immutable class CanvasEvent( @@ -55,17 +62,82 @@ class CanvasEvent( fun channel() = tags.channel() + /** The `expected-revision` precondition: [ExpectedRevisionTag.NONE], a head id, or null for an unconditional write. */ + fun expectedRevision() = tags.firstNotNullOfOrNull(ExpectedRevisionTag::parse) + + /** + * True when this revision displaces [other] as the channel's live canvas under the relay's + * `created_at DESC, id ASC` read order: a newer `created_at` wins, and a same-second tie goes to + * the lexicographically smallest id (not to whichever arrived last). + */ + fun isNewerHeadThan(other: Event?): Boolean = isNewerHead(createdAt, id, other?.createdAt, other?.id) + companion object { const val KIND = 40100 + /** + * The furthest a canvas head may sit in the future before a writer refuses to ratchet past + * it (seconds). Mirrors `CANVAS_MAX_FUTURE_SKEW_SECS` in `buzz-sdk/src/builders.rs`: stamping + * `max(now, head + 1)` against a poisoned far-future head would drag every later write along. + */ + const val MAX_HEAD_FUTURE_SKEW_SECS = 60L + + /** + * How far in the future the relay accepts a canvas `created_at` (seconds) — + * `CANVAS_MAX_INGEST_FUTURE_SECS` in `buzz-relay/src/handlers/ingest.rs`. A write stamped + * by [writeCreatedAt] never exceeds `now + MAX_HEAD_FUTURE_SKEW_SECS + 1`, well inside it. + */ + const val RELAY_MAX_FUTURE_SECS = 300L + + /** OK-message prefix the relay uses when an `expected-revision` precondition fails. */ + const val CONFLICT_PREFIX = "conflict:" + fun build( channelId: String, markdown: String, createdAt: Long = TimeUtils.now(), + expectedRevision: String? = null, initializer: TagArrayBuilder.() -> Unit = {}, ) = eventTemplate(KIND, markdown, createdAt) { channel(channelId) + expectedRevision?.let { addUnique(ExpectedRevisionTag.assemble(it)) } initializer() } + + /** + * Writer discipline for a canvas write composed against a head stamped [headCreatedAt]: + * `max(now, headCreatedAt + 1)`, so the write sorts strictly ahead of that head. Returns + * null — refuse the write — when the head sits more than [MAX_HEAD_FUTURE_SKEW_SECS] in the + * future. With no head ([headCreatedAt] null) it is just [now]. + * + * Mirrors `canvas_write_created_at` in `buzz-sdk/src/builders.rs`. + */ + fun writeCreatedAt( + headCreatedAt: Long?, + now: Long = TimeUtils.now(), + ): Long? { + if (headCreatedAt == null) return now + if (headCreatedAt > now + MAX_HEAD_FUTURE_SKEW_SECS) return null + return maxOf(now, headCreatedAt + 1) + } + + /** + * `created_at DESC, id ASC` head selection: true when revision ([createdAt], [id]) displaces + * the current head ([headCreatedAt], [headId]). No head at all is always displaced; the same + * revision never displaces itself. + */ + fun isNewerHead( + createdAt: Long, + id: String, + headCreatedAt: Long?, + headId: String?, + ): Boolean { + if (headCreatedAt == null || headId == null) return true + if (createdAt != headCreatedAt) return createdAt > headCreatedAt + return id < headId + } + + /** True when a relay OK message reports a failed `expected-revision` compare-and-swap. */ + fun isConflict(okMessage: String?): Boolean = okMessage?.startsWith(CONFLICT_PREFIX) == true } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/stream/StreamMessageV2Event.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/stream/StreamMessageV2Event.kt index e6afeda6de..a726cd5350 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/stream/StreamMessageV2Event.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/stream/StreamMessageV2Event.kt @@ -36,6 +36,10 @@ import com.vitorpamplona.quartz.utils.TimeUtils * replaceable range `10002`, which was wrong). Channel-scoped via the `h` tag; the * text lives in [content], optionally carrying `p` mentions and a `broadcast` flag. * See `KIND_STREAM_MESSAGE_V2` in Buzz's `buzz-core/src/kind.rs`. + * + * **Read-only in practice.** Buzz's own clients (and Amethyst) now write channel messages as kind 9 + * in the same tag shape ([BuzzChatMessage]); this class stays so older 40002 messages still parse + * and render. [build] is kept for tests and tooling. */ @Immutable class StreamMessageV2Event( diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/stream/SystemMessagePayload.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/stream/SystemMessagePayload.kt index 7bdcdf8333..48cfd99f36 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/stream/SystemMessagePayload.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/stream/SystemMessagePayload.kt @@ -35,8 +35,10 @@ import kotlinx.serialization.json.Json * present; [actor] is present on every variant the relay emits today, and the rest are * variant-specific. Unknown keys are ignored for forward compatibility, so a relay that * grows a new field or a new [type] degrades to a plain line instead of failing to parse. + * Two relay-internal variants carry no [actor] at all: [ADMIN_KICK] and [CHANNEL_AUTO_ARCHIVED]. * - * The complete vocabulary, read off `side_effects.rs` / `command_executor.rs`: + * The complete vocabulary, read off `side_effects.rs` / `command_executor.rs` / + * `admin_outbox_worker.rs` / `main.rs`: * * | [type] | carries | emitted when | * |--|--|--| @@ -51,6 +53,8 @@ import kotlinx.serialization.json.Json * | [CHANNEL_CREATED] / [CHANNEL_DELETED] | [actor] | channel lifecycle | * | [MESSAGE_DELETED] | [actor], [targetEventId], optional [actionId] / [reasonCode] / [publicReason] | a message was deleted (moderation tombstone) | * | [DM_CREATED] | [actor], [participants] | a DM channel was opened | + * | [ADMIN_KICK] | [target], [actionId] — **no** [actor] | a relay/workspace administrator removed [target] (delivered by the admin outbox worker) | + * | [CHANNEL_AUTO_ARCHIVED] | nothing | the ephemeral-channel reaper archived an idle channel | */ @Serializable data class SystemMessagePayload( @@ -80,7 +84,7 @@ data class SystemMessagePayload( */ fun subject(): String? = when (type) { - MEMBER_JOINED, MEMBER_LEFT, MEMBER_REMOVED -> target ?: actor + MEMBER_JOINED, MEMBER_LEFT, MEMBER_REMOVED, ADMIN_KICK -> target ?: actor else -> actor } @@ -108,6 +112,12 @@ data class SystemMessagePayload( const val MESSAGE_DELETED = "message_deleted" const val DM_CREATED = "dm_created" + /** An administrator removed [target]; relay-authored with no [actor] (`admin_outbox_worker.rs`). */ + const val ADMIN_KICK = "admin_kick" + + /** The relay's ephemeral-channel reaper archived the channel; carries no [actor] (`main.rs`). */ + const val CHANNEL_AUTO_ARCHIVED = "channel_auto_archived" + /** Searchable, anyone can join. */ const val VISIBILITY_OPEN = "open" diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/stream/tags/ExpectedRevisionTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/stream/tags/ExpectedRevisionTag.kt new file mode 100644 index 0000000000..308d3a7f16 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/stream/tags/ExpectedRevisionTag.kt @@ -0,0 +1,65 @@ +/* + * 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.buzz.stream.tags + +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.Hex +import com.vitorpamplona.quartz.utils.ensure + +/** + * The optional compare-and-swap precondition on a Buzz canvas write (`kind:40100`): + * `["expected-revision", "none" | <64-hex event id>]`. + * + * A 64-hex id names the canvas head the write was composed against; the literal [NONE] asserts + * that no head exists yet. The relay reads the live head under a lock and rejects a mismatch with + * a `conflict:` OK message before storing anything; a canvas write without the tag is an + * unconditional append. + * + * Mirrors `build_set_canvas` in Buzz's `buzz-sdk/src/builders.rs` and + * `parse_canvas_expected_revision` in `buzz-relay/src/handlers/ingest.rs`: the tag has exactly one + * value, and the relay rejects (`invalid:`) any other shape, so [parse] only accepts that one. + */ +class ExpectedRevisionTag { + companion object { + const val TAG_NAME = "expected-revision" + + /** The sentinel value asserting the channel has no canvas head yet. */ + const val NONE = "none" + + /** True for [NONE] or a 64-character hex event id — the only values the relay accepts. */ + fun isValidValue(value: String): Boolean = value == NONE || (value.length == 64 && Hex.isHex(value)) + + fun parse(tag: Array): String? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag.size == 2) { return null } + ensure(isValidValue(tag[1])) { return null } + return tag[1] + } + + fun assemble(expectedRevision: String): Array { + require(isValidValue(expectedRevision)) { + "expected-revision must be the literal \"none\" or a 64-character hex event id (got \"$expectedRevision\")" + } + return arrayOf(TAG_NAME, expectedRevision) + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/teamCatalog/AcpSessionPolicy.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/teamCatalog/AcpSessionPolicy.kt new file mode 100644 index 0000000000..393c1a9612 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/teamCatalog/AcpSessionPolicy.kt @@ -0,0 +1,65 @@ +/* + * 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.buzz.teamCatalog + +import kotlinx.serialization.KSerializer +import kotlinx.serialization.Serializable +import kotlinx.serialization.descriptors.PrimitiveKind +import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder +import kotlinx.serialization.json.JsonDecoder +import kotlinx.serialization.json.JsonPrimitive + +/** + * Whether one ACP conversation is shared by a whole channel ([CHANNEL], the default) or kept + * per thread ([THREAD]). Wire values are `"channel"` / `"thread"`; like upstream's lenient + * `Deserialize`, anything other than the string `"thread"` (including `null`, a number or an + * unknown string) reads as [CHANNEL]. Ground truth: `AcpSessionPolicy` in Buzz's + * `desktop/src-tauri/src/managed_agents/session_policy.rs`. + */ +@Serializable(with = AcpSessionPolicySerializer::class) +enum class AcpSessionPolicy( + val code: String, +) { + CHANNEL("channel"), + THREAD("thread"), +} + +object AcpSessionPolicySerializer : KSerializer { + override val descriptor = PrimitiveSerialDescriptor("buzz.AcpSessionPolicy", PrimitiveKind.STRING) + + override fun serialize( + encoder: Encoder, + value: AcpSessionPolicy, + ) = encoder.encodeString(value.code) + + override fun deserialize(decoder: Decoder): AcpSessionPolicy { + val element = (decoder as? JsonDecoder)?.decodeJsonElement() + val value = + if (element == null) { + decoder.decodeString() + } else { + (element as? JsonPrimitive)?.takeIf { it.isString }?.content + } + return if (value == AcpSessionPolicy.THREAD.code) AcpSessionPolicy.THREAD else AcpSessionPolicy.CHANNEL + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/teamCatalog/TeamCatalogContent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/teamCatalog/TeamCatalogContent.kt new file mode 100644 index 0000000000..6f8d34b885 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/teamCatalog/TeamCatalogContent.kt @@ -0,0 +1,214 @@ +/* + * 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.buzz.teamCatalog + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.decodeFromString +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json + +/** + * The JSON body of a Buzz team-catalog event ([TeamCatalogEvent], `kind:30178`): a team plus + * every member's safe, portable definition embedded in full, so a recipient can rebuild the + * team without reading the owner's personas. + * + * Mirrors `TeamCatalogContent` in Buzz's `desktop/src-tauri/src/managed_agents/team_catalog.rs` + * field for field **and in declaration order**: serde emits in that order, the content bytes + * are the canonical projection (they fix the event id, and the owner's freshness reconcile + * compares them), so a reorder here would read as a changed team. Optional fields are omitted + * when absent ([JSON] has `encodeDefaults = false`, matching serde's `skip_serializing_if`), + * while [v] and [members] are always written and are required on read — a body without `v` + * must not masquerade as v1. + * + * [validate] ports the v1 size/shape contract (`validate_team_catalog_content`); see its KDoc + * for the parts it leaves out. + */ +@Serializable +data class TeamCatalogContent( + /** Schema version; readers must refuse anything but [SCHEMA_VERSION]. */ + val v: Int, + val name: String, + val description: String? = null, + val instructions: String? = null, + /** Members in the team's own order — part of the canonical bytes. */ + val members: List, +) { + fun encodeToJson(): String = JSON.encodeToString(this) + + /** + * Returns the first reason upstream's reader would refuse this body, or null when it + * passes: the schema version, the team name/description/instructions bounds, the member + * cap, every member's field contract, unique member keys, and the total encoded size. + * + * Not ported: the concealment rule on visible text (`validate_visible_text` — + * invisible/bidi control rejection) and the recomputation of `projection_hash` from the + * member's own fields; only the hint pair's completeness and hash shape are checked. The + * avatar URL check approximates upstream's WHATWG parse (see [isSafeCatalogAvatarUrl]). + */ + fun validate(): String? { + if (v != SCHEMA_VERSION) return "unsupported team catalog schema version $v (expected $SCHEMA_VERSION)" + if (name.isBlank()) return "invalid team projection: the team name is empty" + if (name.utf8Size() > MAX_NAME_BYTES) return "team too large to share: the team name exceeds $MAX_NAME_BYTES bytes" + if (description != null && description.utf8Size() > MAX_TEXT_BYTES) return "team too large to share: the team description exceeds $MAX_TEXT_BYTES bytes" + if (instructions != null && instructions.utf8Size() > MAX_INSTRUCTIONS_BYTES) return "team too large to share: the team instructions exceed $MAX_INSTRUCTIONS_BYTES bytes" + if (members.size > MAX_MEMBERS) return "team too large to share: ${members.size} members (limit $MAX_MEMBERS)" + val seen = HashSet() + for (member in members) { + member.validate()?.let { return it } + if (!seen.add(member.memberKey)) return "invalid team projection: '${member.displayName}' repeats the member key '${member.memberKey}' of an earlier member" + } + val encoded = encodeToJson().utf8Size() + if (encoded > MAX_TOTAL_BYTES) return "team too large to share: the projection is $encoded bytes (limit $MAX_TOTAL_BYTES)" + return null + } + + fun isValid() = validate() == null + + companion object { + const val SCHEMA_VERSION = 1 + + const val MAX_MEMBERS = 64 + const val MAX_NAME_BYTES = 256 + const val MAX_TEXT_BYTES = 4 * 1024 + const val MAX_INSTRUCTIONS_BYTES = 16 * 1024 + const val MAX_SYSTEM_PROMPT_BYTES = 16 * 1024 + const val MAX_AVATAR_URL_BYTES = 32 * 1024 + const val MAX_NAME_POOL_ENTRIES = 64 + const val MAX_TOTAL_BYTES = 192 * 1024 + const val MAX_MEMBER_KEY_BYTES = 128 + const val MAX_IDENTIFIER_BYTES = 256 + const val MAX_BUILTIN_SLUG_BYTES = 128 + const val PROJECTION_HASH_HEX_LEN = 64 + + /** The `respond_to` modes upstream accepts (`RespondTo::parse_wire`). */ + val RESPOND_TO_MODES = setOf("owner-only", "allowlist", "anyone") + + val JSON = + Json { + ignoreUnknownKeys = true + explicitNulls = false + encodeDefaults = false + } + + fun decodeFromJson(json: String): TeamCatalogContent = JSON.decodeFromString(json) + + /** + * Upstream's avatar allowlist (`is_safe_catalog_avatar_url`): an `http(s)` URL of at + * most 2048 bytes with no whitespace or parentheses, an inline + * `data:image/svg+xml,` of at most 8192 bytes, or a strict-base64 inline + * png/jpeg/gif/webp of at most 256 KiB. Upstream also runs the WHATWG URL parser on + * the `http(s)` form; this checks only the scheme, so a malformed authority passes here. + */ + fun isSafeCatalogAvatarUrl(url: String): Boolean { + if (!url.startsWith("data:")) { + if (url.utf8Size() > 2048) return false + if (url.any { (it.isWhitespace() && it != '\u0085') || it == '\uFEFF' || it == '(' || it == ')' }) return false + val lower = url.lowercase() + return lower.startsWith("http:") || lower.startsWith("https:") + } + if (url.startsWith("data:image/svg+xml,")) return url.length <= 8192 + if (url.length > 256 * 1024) return false + val rest = url.removePrefix("data:image/") + if (rest.length == url.length) return false + for (mime in listOf("png", "jpeg", "gif", "webp")) { + val prefix = "$mime;base64," + if (!rest.startsWith(prefix)) continue + val b64 = rest.substring(prefix.length) + val trimmed = b64.trimEnd('=') + if (b64.length - trimmed.length <= 2 && + trimmed.all { it in 'A'..'Z' || it in 'a'..'z' || it in '0'..'9' || it == '+' || it == '/' } && + b64.length % 4 == 0 + ) { + return true + } + } + return false + } + + internal fun String.utf8Size() = encodeToByteArray().size + } +} + +/** + * One member's safe definition inside a [TeamCatalogContent]. Mirrors `TeamCatalogMember` in + * Buzz's `desktop/src-tauri/src/managed_agents/team_catalog.rs`, in declaration order. + * + * [memberKey] is an opaque, publication-unique identity (a domain-separated SHA-256 of the + * publisher's local id) — never resolve it as a `kind:30175` coordinate. [builtinSlug] and + * [projectionHash] are an all-or-nothing reuse *hint*, not an identity. + */ +@Serializable +data class TeamCatalogMember( + @SerialName("member_key") val memberKey: String, + @SerialName("display_name") val displayName: String, + @SerialName("system_prompt") val systemPrompt: String? = null, + @SerialName("avatar_url") val avatarUrl: String? = null, + val runtime: String? = null, + @SerialName("acp_command") val acpCommand: String? = null, + val model: String? = null, + val provider: String? = null, + @SerialName("name_pool") val namePool: List = emptyList(), + @SerialName("respond_to") val respondTo: String? = null, + val parallelism: Int? = null, + @SerialName("session_policy") val sessionPolicy: AcpSessionPolicy = AcpSessionPolicy.CHANNEL, + @SerialName("builtin_slug") val builtinSlug: String? = null, + @SerialName("projection_hash") val projectionHash: String? = null, +) { + /** The first v1-contract violation (`validate_member` upstream), or null. */ + fun validate(): String? { + val who = displayName + if (memberKey.isBlank()) return "invalid team projection: a member key is empty" + if (memberKey.utf8Size() > TeamCatalogContent.MAX_MEMBER_KEY_BYTES) return "team too large to share: a member key is too long" + if (displayName.isBlank()) return "invalid team projection: a member display name is empty" + if (displayName.utf8Size() > TeamCatalogContent.MAX_NAME_BYTES) return "team too large to share: a member display name is too long" + if (systemPrompt != null && systemPrompt.utf8Size() > TeamCatalogContent.MAX_SYSTEM_PROMPT_BYTES) return "team too large to share: the system prompt for '$who' is too long" + if (avatarUrl != null) { + if (avatarUrl.utf8Size() > TeamCatalogContent.MAX_AVATAR_URL_BYTES) return "team too large to share: the avatar for '$who' is too large" + if (!TeamCatalogContent.isSafeCatalogAvatarUrl(avatarUrl)) return "invalid team projection: the avatar for '$who' uses an unsafe URL scheme" + } + for ((value, label) in listOf(runtime to "runtime", model to "model", provider to "provider")) { + if (value == null) continue + if (value.isBlank()) return "invalid team projection: the $label for '$who' is empty" + if (value.utf8Size() > TeamCatalogContent.MAX_IDENTIFIER_BYTES) return "team too large to share: the $label for '$who' is too long" + } + if (namePool.size > TeamCatalogContent.MAX_NAME_POOL_ENTRIES) return "team too large to share: '$who' has ${namePool.size} name-pool entries" + for (entry in namePool) { + if (entry.isBlank()) return "invalid team projection: a name-pool entry for '$who' is empty" + if (entry.utf8Size() > TeamCatalogContent.MAX_NAME_BYTES) return "team too large to share: a name-pool entry for '$who' is too long" + } + if (respondTo != null && respondTo !in TeamCatalogContent.RESPOND_TO_MODES) return "definition respond_to '$respondTo' is not a recognized mode" + if (parallelism != null && parallelism !in 1..32) return "invalid team projection: parallelism $parallelism for '$who' is out of range" + if ((builtinSlug == null) != (projectionHash == null)) return "invalid team projection: '$who' has an incomplete built-in reuse hint" + if (builtinSlug != null && projectionHash != null) { + if (builtinSlug.isBlank()) return "invalid team projection: the built-in slug for '$who' is empty" + if (builtinSlug.utf8Size() > TeamCatalogContent.MAX_BUILTIN_SLUG_BYTES) return "team too large to share: the built-in slug for '$who' is too long" + if (projectionHash.length != TeamCatalogContent.PROJECTION_HASH_HEX_LEN || + !projectionHash.all { it in '0'..'9' || it in 'a'..'f' || it in 'A'..'F' } + ) { + return "invalid team projection: the reuse hash for '$who' is not a SHA-256 hex digest" + } + } + return null + } + + private fun String.utf8Size() = encodeToByteArray().size +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/teamCatalog/TeamCatalogEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/teamCatalog/TeamCatalogEvent.kt new file mode 100644 index 0000000000..0aeb388e12 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/teamCatalog/TeamCatalogEvent.kt @@ -0,0 +1,162 @@ +/* + * 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.buzz.teamCatalog + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.buzz.apPersonas.PersonaEvent +import com.vitorpamplona.quartz.buzz.apPersonas.tags.SharedTag +import com.vitorpamplona.quartz.nip01Core.core.BaseAddressableEvent +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.TagArray +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate +import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate +import com.vitorpamplona.quartz.nip01Core.tags.dTag.DTag +import com.vitorpamplona.quartz.nip01Core.tags.dTag.dTag +import kotlinx.coroutines.CancellationException + +/** + * A Buzz team catalog entry (`kind:30178`): the shareable projection of a team — its name, + * description, instructions and every member's safe definition embedded in full + * ([TeamCatalogContent]) — so another community member can adopt the team without reading + * the owner's personas. Addressable by `(owner, 30178, d)` where `d` is the team's stable id, + * the same `d` as its `kind:30176` [com.vitorpamplona.quartz.buzz.teams.TeamEvent]. The two + * kinds are separate so an ordinary team edit (30176) cannot disturb the catalog's share + * state, which lives only on this head's [SharedTag]. + * + * Read access is **author-only unless shared**: the relay serves the head to others only when + * it carries exactly `["shared","true"]`; an untagged head is the durable "published but not + * discoverable" state unsharing produces. A reader keys by `(pubkey, d)`, takes the newest + * head, and only then checks sharing and parses — a newer unshared or malformed head must not + * resurrect an older shared one. + * + * Tags: exactly one `d` (non-empty, at most 64 characters, no control characters or + * whitespace — `single_bounded_d_tag`) and at most one exact `["shared","true"]` + * (`validate_shared_tag`). Ground truth: `build_team_catalog_event` / + * `team_catalog_content_from_event` in Buzz's + * `desktop/src-tauri/src/managed_agents/team_catalog.rs`, the reader in + * `desktop/src-tauri/src/team_catalog.rs`, and `validate_team_catalog_envelope` in + * `buzz-relay/src/handlers/ingest.rs`. + */ +@Immutable +class TeamCatalogEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig) { + /** The team's stable id — the `d` tag (shared with its `kind:30176`). */ + fun teamId() = dTag() + + /** True when the entry is discoverable by the community (`["shared","true"]`). */ + fun isShared() = SharedTag.isShared(tags) + + /** True when the tags pass the relay's ingest envelope check. */ + fun isEnvelopeValid() = envelopeError(tags) == null + + /** + * Parses the body, all-or-nothing like upstream: throws when the JSON is malformed, the + * schema version is not 1, or any field breaks the v1 contract. Use [catalogOrNull]. + */ + fun catalog(): TeamCatalogContent { + val parsed = TeamCatalogContent.decodeFromJson(content) + parsed.validate()?.let { throw IllegalArgumentException(it) } + return parsed + } + + fun catalogOrNull(): TeamCatalogContent? = + try { + catalog() + } catch (e: Exception) { + if (e is CancellationException) throw e + null + } + + companion object { + const val KIND = 30178 + + /** Maximum characters in the `d` tag (`single_bounded_d_tag`). */ + const val MAX_D_CHARS = 64 + + /** + * The relay's ingest check for this kind, or null when it passes: exactly one `d` + * (a valueless `["d"]` counts), non-empty, at most [MAX_D_CHARS] characters, with no + * control or whitespace characters; and no `shared` tag other than a single exact + * `["shared","true"]`. + */ + fun envelopeError(tags: TagArray): String? { + var sharedCount = 0 + var dCount = 0 + var d: String? = null + for (tag in tags) { + if (tag.isEmpty()) continue + when (tag[0]) { + SharedTag.TAG_NAME -> sharedCount++ + DTag.TAG_NAME -> { + dCount++ + d = tag.getOrNull(1) ?: "" + } + } + } + if (sharedCount > 0 && !SharedTag.isShared(tags)) return "team-catalog event `shared` tag must be exactly one [\"shared\",\"true\"]" + if (dCount != 1) return "team-catalog event must have exactly one `d` tag (got $dCount)" + return teamIdError(d ?: "") + } + + /** Why [teamId] cannot be a catalog `d` value, or null when it can. */ + fun teamIdError(teamId: String): String? { + if (teamId.isEmpty()) return "team-catalog event `d` tag must not be empty" + var chars = 0 + for (c in teamId) { + if (!c.isLowSurrogate()) chars++ + if (c.isISOControl() || c.isWhitespace()) return "team-catalog event `d` tag must not contain control characters or whitespace" + } + if (chars > MAX_D_CHARS) return "team-catalog event `d` tag too long ($chars chars, max $MAX_D_CHARS)" + return null + } + + /** + * Builds a catalog head the way `build_team_catalog_event` does: `d` = [teamId], plus + * `["shared","true"]` only when [shared] (unsharing republishes without it). The body + * must pass [TeamCatalogContent.validate]. Republishing should pass the replaced + * head's [priorHeadCreatedAt] so the new head sorts after it even when this clock + * lags (upstream signs with `monotonic_created_at`). + */ + fun build( + catalog: TeamCatalogContent, + teamId: String, + shared: Boolean, + priorHeadCreatedAt: Long? = null, + createdAt: Long = PersonaEvent.monotonicCreatedAt(priorHeadCreatedAt), + initializer: TagArrayBuilder.() -> Unit = {}, + ): EventTemplate { + teamIdError(teamId)?.let { throw IllegalArgumentException(it) } + catalog.validate()?.let { throw IllegalArgumentException(it) } + return eventTemplate(KIND, catalog.encodeToJson(), createdAt) { + dTag(teamId) + if (shared) addUnique(SharedTag.assemble()) + initializer() + } + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/threading/TagArrayExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/threading/TagArrayExt.kt index 132ab907b7..d36aec4c89 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/threading/TagArrayExt.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/threading/TagArrayExt.kt @@ -25,22 +25,60 @@ import com.vitorpamplona.quartz.nip01Core.core.TagArray import com.vitorpamplona.quartz.nip10Notes.tags.MarkedETag /** - * Positional reader for Buzz's thread e-tags (`["e", id, "", "root"|"reply"]`). - * [MarkedETag.parse] rejects the empty relay slot Buzz emits, so the marker is read - * positionally: index 3 in the canonical 4-element form, tolerating the relay-less - * 3-element form (`["e", id, "reply"]`) with the marker at index 2. + * The `root` / `reply` markers on an event's `e` tags, read exactly the way Buzz's relay reads + * them (`buzz-core/src/nip10.rs`, shared by ingest, ACP anchoring and the CLI): + * + * - only `["e", , , ]` tags with at least 4 elements count, so the marker is + * always at index 3 (a 3-element `["e", id, "reply"]` is not a thread link); + * - the id must be exactly 64 hex characters, otherwise the tag is ignored; + * - the **last** valid occurrence of each marker wins. */ -private fun Array.buzzMarkedEventId(marker: MarkedETag.MARKER): HexKey? { - if (size < 3 || this[0] != MarkedETag.TAG_NAME || this[1].length != 64) return null - // The marker lives at index 3 in the canonical 4-element form. Only fall back to - // index 2 for the EXACT 3-element form: on a 4+-element tag, index 2 is the relay - // hint, and a hint that happened to read "root"/"reply" would misparse. - val markerIndex = if (size == 3) 2 else 3 - return if (this[markerIndex] == marker.code) this[1] else null +data class BuzzThreadMarkers( + val root: HexKey?, + val reply: HexKey?, +) { + /** + * Collapses the markers into this reply's `(root, parent)`, or null when the event is + * top-level. A lone `reply` marker is a direct reply to the thread root, so it is both; a lone + * `root` marker does **not** make a reply (the relay treats the event as top-level). + */ + fun resolve(): Pair? = + when { + reply == null -> null + root == null -> reply to reply + else -> root to reply + } } +private fun isEventIdHex(id: String): Boolean = id.length == 64 && id.all { it in '0'..'9' || it in 'a'..'f' || it in 'A'..'F' } + +/** Parses the Buzz thread markers from these tags (see [BuzzThreadMarkers]). */ +fun TagArray.buzzThreadMarkers(): BuzzThreadMarkers { + var root: HexKey? = null + var reply: HexKey? = null + for (tag in this) { + if (tag.size < 4 || tag[0] != MarkedETag.TAG_NAME || !isEventIdHex(tag[1])) continue + when (tag[3]) { + MarkedETag.MARKER.ROOT.code -> root = tag[1] + MarkedETag.MARKER.REPLY.code -> reply = tag[1] + } + } + return BuzzThreadMarkers(root, reply) +} + +/** This event's `(root, parent)` when it is a thread reply; null when it is top-level. */ +fun TagArray.buzzThreadAncestry(): Pair? = buzzThreadMarkers().resolve() + +/** + * The thread a reply to an event with these tags belongs under: the event's own resolved root + * when it is itself a reply, else [ownId] (the event starts the thread). This is the root the + * relay derives from the parent (`derive_ancestry_from_parent_tags`) and rejects a mismatch of + * with `root tag does not match thread ancestry`. + */ +fun TagArray.buzzThreadRootForReplyTo(ownId: HexKey): HexKey = buzzThreadAncestry()?.first ?: ownId + /** The thread root this event replies under, from its marked `root` e-tag. */ -fun TagArray.buzzThreadRoot(): HexKey? = firstNotNullOfOrNull { it.buzzMarkedEventId(MarkedETag.MARKER.ROOT) } +fun TagArray.buzzThreadRoot(): HexKey? = buzzThreadMarkers().root /** The direct parent this event replies to, from its marked `reply` e-tag. */ -fun TagArray.buzzThreadReply(): HexKey? = firstNotNullOfOrNull { it.buzzMarkedEventId(MarkedETag.MARKER.REPLY) } +fun TagArray.buzzThreadReply(): HexKey? = buzzThreadMarkers().reply diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/workflow/TagArrayBuilderExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/workflow/TagArrayBuilderExt.kt index a48aca0205..066919af14 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/workflow/TagArrayBuilderExt.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/workflow/TagArrayBuilderExt.kt @@ -41,5 +41,14 @@ fun TagArrayBuilder.workflowDTag(value: String) = addUnique(DTag. /** The optional human-readable workflow `name` tag on a definition. */ fun TagArrayBuilder.workflowName(name: String) = addUnique(arrayOf("name", name)) +/** + * The compare-and-swap precondition on a definition update: the id of the `kind:30620` head the + * edit was made against. The relay applies the update only while that event is still the head, + * and otherwise refuses with a `conflict:` reason. It must be a 64-hex event id; anything else + * is refused as `invalid: bad expected workflow revision`. Ground truth: + * `parse_expected_workflow_revision` in Buzz's `buzz-relay/src/handlers/command_executor.rs`. + */ +fun TagArrayBuilder.workflowExpectedRevision(headEventId: HexKey) = addUnique(arrayOf(WORKFLOW_EXPECTED_REVISION_TAG, headEventId)) + /** The `p` tag naming the approver a `kind:46010` approval-requested event is addressed to. */ fun TagArrayBuilder.workflowApprover(approverPubKey: HexKey) = addUnique(PTag.assemble(approverPubKey, null)) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/workflow/TagArrayExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/workflow/TagArrayExt.kt index ead421801e..c6319fd66e 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/workflow/TagArrayExt.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/workflow/TagArrayExt.kt @@ -27,6 +27,8 @@ import com.vitorpamplona.quartz.nip01Core.tags.dTag.DTag import com.vitorpamplona.quartz.nip01Core.tags.people.PTag import com.vitorpamplona.quartz.nip29RelayGroups.tags.GroupIdTag +const val WORKFLOW_EXPECTED_REVISION_TAG = "expected-revision" + /** The `h` channel UUID a workflow event belongs to. */ fun TagArray.workflowChannel(): String? = firstTagValue(GroupIdTag.TAG_NAME) @@ -36,5 +38,8 @@ fun TagArray.workflowDTag(): String? = firstTagValue(DTag.TAG_NAME) /** The optional workflow `name`. */ fun TagArray.workflowName(): String? = firstTagValue("name") +/** The CAS precondition (`expected-revision`) a definition update was made against, if any. */ +fun TagArray.workflowExpectedRevision(): HexKey? = firstTagValue(WORKFLOW_EXPECTED_REVISION_TAG) + /** The approver pubkey (`p` tag) an approval-requested event is addressed to. */ fun TagArray.workflowApprover(): HexKey? = firstNotNullOfOrNull(PTag::parseKey) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/workflow/WorkflowDefEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/workflow/WorkflowDefEvent.kt index 0697315d48..503cd8f002 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/workflow/WorkflowDefEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/workflow/WorkflowDefEvent.kt @@ -36,6 +36,11 @@ import com.vitorpamplona.quartz.utils.TimeUtils * a channel; an optional `name` tag labels it. The relay parses the YAML, upserts by the * `(owner, d)` address, and preserves any webhook secret across updates. Ground truth: * `handle_workflow_def` in Buzz's `buzz-relay/src/handlers/command_executor.rs`. + * + * The `d` tag MUST parse as a UUID (the relay refuses anything else with + * `invalid: bad workflow_id format`). An update should carry `expected-revision` = the id of the + * head it was edited from, as Buzz's `build_workflow_update` does, so a concurrent edit is + * refused with `conflict:` instead of silently overwritten. */ @Immutable class WorkflowDefEvent( @@ -65,6 +70,9 @@ class WorkflowDefEvent( /** The optional human-readable workflow name. */ fun name() = tags.workflowName() + /** The head this update was made against (the CAS precondition), if any. */ + fun expectedRevision() = tags.workflowExpectedRevision() + /** The workflow YAML source - the event `content`. */ fun yaml() = content @@ -76,12 +84,14 @@ class WorkflowDefEvent( channelId: String, yaml: String, name: String? = null, + expectedRevision: HexKey? = null, createdAt: Long = TimeUtils.now(), initializer: TagArrayBuilder.() -> Unit = {}, ) = eventTemplate(KIND, yaml, createdAt) { dTag(workflowId) workflowChannel(channelId) name?.let { workflowName(it) } + expectedRevision?.let { workflowExpectedRevision(it) } initializer() } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/workspace/BuzzChannelMetadata.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/workspace/BuzzChannelMetadata.kt index 79b3aedbd3..be812e6010 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/workspace/BuzzChannelMetadata.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/workspace/BuzzChannelMetadata.kt @@ -59,6 +59,23 @@ const val BUZZ_CHANNEL_TYPE_STREAM = "stream" const val BUZZ_VISIBILITY_OPEN = "open" const val BUZZ_VISIBILITY_PRIVATE = "private" +/** + * The canonical form Buzz stores a channel name in: leading `#`s and whitespace (in any mix) and + * trailing whitespace removed, since clients render the `#` themselves. A `#` after the first real + * character is kept (`"channel#topic"`). + * + * Mirrors `canonical_channel_name` in `crates/buzz-core/src/channel.rs`, which the relay applies to + * the `name` of a create (9007) and a rename (9002), and which its SDK applies before sending. + */ +fun canonicalBuzzChannelName(name: String): String = name.trimStart { it == '#' || it.isWhitespace() }.trimEnd() + +/** + * True when [name] survives [canonicalBuzzChannelName] with something left. The relay refuses a + * create or rename whose canonical name is empty (`invalid: channel name is required`), so a name + * made only of `#`s and whitespace must be refused before sending. + */ +fun isValidBuzzChannelName(name: String): Boolean = canonicalBuzzChannelName(name).isNotBlank() + /** * A new Buzz channel id: a RFC-4122 v4 UUID string. * @@ -87,3 +104,6 @@ const val BUZZ_ROLE_ADMIN = "admin" const val BUZZ_ROLE_MEMBER = "member" const val BUZZ_ROLE_GUEST = "guest" const val BUZZ_ROLE_BOT = "bot" + +/** Every role string Buzz's put-user handler parses. */ +val BUZZ_ROLES = setOf(BUZZ_ROLE_OWNER, BUZZ_ROLE_ADMIN, BUZZ_ROLE_MEMBER, BUZZ_ROLE_GUEST, BUZZ_ROLE_BOT) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/workspace/BuzzCustomEmoji.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/workspace/BuzzCustomEmoji.kt new file mode 100644 index 0000000000..7a071ac522 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/workspace/BuzzCustomEmoji.kt @@ -0,0 +1,52 @@ +/* + * 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.buzz.workspace + +/** + * Buzz's custom-emoji shortcode rule (`normalize_custom_emoji_shortcode` in + * `crates/buzz-sdk/src/builders.rs`). The relay runs it over every `emoji` tag of a NIP-30 emoji + * set (`kind:30030`) or emoji list (`kind:10030`) at ingest (`validate_custom_emoji_tags` in + * `buzz-relay/src/handlers/ingest.rs`) and rejects the whole event on the first failure. + * + * It is NIP-30's `[A-Za-z0-9_-]` alphabet plus a length cap NIP-30 does not have + * ([MAX_SHORTCODE_BYTES]); surrounding whitespace and `:` delimiters are stripped first. + */ +object BuzzCustomEmoji { + /** `MAX_CUSTOM_EMOJI_SHORTCODE_LEN`: the longest shortcode a Buzz relay accepts, in bytes. */ + const val MAX_SHORTCODE_BYTES = 64 + + /** + * The shortcode as Buzz normalizes it — trimmed, `:` delimiters removed, lowercased — or null + * when a Buzz relay would reject it (empty, over [MAX_SHORTCODE_BYTES] bytes, or a character + * outside `[A-Za-z0-9_-]`). + */ + fun normalizeShortcode(shortcode: String): String? { + val trimmed = shortcode.trim().trim(':') + if (trimmed.isEmpty()) return null + // The alphabet is ASCII-only, so the byte length equals the char length once it passes. + if (trimmed.length > MAX_SHORTCODE_BYTES) return null + if (!trimmed.all { it in 'a'..'z' || it in 'A'..'Z' || it in '0'..'9' || it == '-' || it == '_' }) return null + return trimmed.lowercase() + } + + /** True when a Buzz relay would accept [shortcode] in a 30030/10030 `emoji` tag. */ + fun isValidShortcode(shortcode: String): Boolean = normalizeShortcode(shortcode) != null +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/pool/PoolRequests.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/pool/PoolRequests.kt index 16db5e817e..12e1fb959e 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/pool/PoolRequests.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/pool/PoolRequests.kt @@ -549,6 +549,9 @@ class PoolRequests( * - `rate-limited` — the adaptive limiter spaces the REQ out; it's not doomed. * - no recognized prefix — lifecycle noise (`Subscription closed`, `not found`, …); * don't risk silencing a live sub on an unstructured message. + * - an `error:` that reports a timeout (Buzz closes a REQ that hits its query deadline + * with `error: query timed out` instead of EOSE) — a slow moment, not a refusal of the + * filter; counting it would stop re-sending a REQ the relay serves when less loaded. * * MUST be called while holding [state]'s lock. */ @@ -567,10 +570,10 @@ class PoolRequests( MachineReadablePrefix.DUPLICATE, MachineReadablePrefix.POW, -> false + MachineReadablePrefix.ERROR -> !isTimeout(reason) MachineReadablePrefix.BLOCKED, MachineReadablePrefix.INVALID, MachineReadablePrefix.RESTRICTED, - MachineReadablePrefix.ERROR, MachineReadablePrefix.UNSUPPORTED, -> true } @@ -579,6 +582,8 @@ class PoolRequests( state.recordRefusal(relay, forFilters, sameAsLast) } + private fun isTimeout(reason: String): Boolean = reason.contains("timed out", ignoreCase = true) || reason.contains("timeout", ignoreCase = true) + fun destroy() { relayState.clear() desiredSubs.clear() diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/moderation/GroupPutUserEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/moderation/GroupPutUserEvent.kt index 6faf482d43..d9add45f58 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/moderation/GroupPutUserEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/moderation/GroupPutUserEvent.kt @@ -50,8 +50,10 @@ class GroupPutUserEvent( * what relay29 reads. * * [buzzRole] additionally emits a top-level `["role", …]` tag. Buzz reads **only** that — - * `extract_tag_value(event, "role")`, defaulting to `member` — so without it every put-user - * lands as a plain member and a promotion silently does nothing. Its vocabulary is also its + * `extract_tag_value(event, "role")` — so a promotion must carry it. Without it Buzz makes + * **no role change**: an existing member keeps their role and only a newcomer defaults to + * `member`, which is what a plain "add" wants. Changing an active member's role (either way) + * is owner/admin-only, and the last owner can't be demoted. Its vocabulary is also its * own (`owner`/`admin`/`member`/`guest`/`bot`, no moderator); an unparseable role fails the * whole handler, so callers map to Buzz's set before passing it here. Harmless on relay29, * which ignores the extra tag. diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip38UserStatus/UserStatusEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip38UserStatus/UserStatusEvent.kt index db4faeae99..0f25484373 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip38UserStatus/UserStatusEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip38UserStatus/UserStatusEvent.kt @@ -29,6 +29,7 @@ import com.vitorpamplona.quartz.nip01Core.tags.aTag.ATag import com.vitorpamplona.quartz.nip01Core.tags.events.ETag import com.vitorpamplona.quartz.nip01Core.tags.people.PTag import com.vitorpamplona.quartz.nip30CustomEmoji.EmojiUrlTag +import com.vitorpamplona.quartz.nip38UserStatus.tags.StatusEmojiTag import com.vitorpamplona.quartz.nip50Search.IndexableFieldVisitor import com.vitorpamplona.quartz.nip50Search.SearchableEvent import com.vitorpamplona.quartz.utils.TimeUtils @@ -53,6 +54,18 @@ class UserStatusEvent( fun firstTaggedUrl() = tags.firstTagValue("r") + /** + * The plain status emoji from a 2-element `["emoji", "🎉"]` tag (Buzz's status shape), or null. + * NIP-30 custom emoji tags (3+ elements) are not status emoji and are read by `taggedEmojis()`. + */ + fun statusEmoji(): String? = tags.firstNotNullOfOrNull(StatusEmojiTag::parse) + + /** + * True when this status carries nothing to show: blank text and no [statusEmoji]. Buzz clears a + * status that way, and an emoji with blank text is still a status ("🌴" alone reads as away). + */ + fun isCleared(): Boolean = content.isBlank() && statusEmoji() == null + companion object { const val KIND = 30315 diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip38UserStatus/tags/StatusEmojiTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip38UserStatus/tags/StatusEmojiTag.kt new file mode 100644 index 0000000000..007c3576e6 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip38UserStatus/tags/StatusEmojiTag.kt @@ -0,0 +1,50 @@ +/* + * 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.nip38UserStatus.tags + +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.ensure + +/** + * A plain status emoji on a NIP-38 user status: the **2-element** `["emoji", ]` that + * Buzz writes (`build_user_status` in `buzz-sdk/src/builders.rs`), e.g. `["emoji", "🎉"]`. + * + * This is not a NIP-30 custom emoji: those are `["emoji", , , …]` with at + * least three elements and are read by + * [com.vitorpamplona.quartz.nip30CustomEmoji.EmojiUrlTag]. The two shapes never overlap, so + * [parse] only takes the exact 2-element form and leaves the NIP-30 one alone. + */ +class StatusEmojiTag { + companion object { + const val TAG_NAME = "emoji" + + fun parse(tag: Array): String? { + ensure(tag.has(1)) { return null } + ensure(tag.size == 2) { return null } + ensure(tag[0] == TAG_NAME) { return null } + val emoji = tag[1].trim() + ensure(emoji.isNotEmpty()) { return null } + return emoji + } + + fun assemble(emoji: String) = arrayOf(TAG_NAME, emoji) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt index cb8d8ba883..8e42496847 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt @@ -27,8 +27,11 @@ import com.vitorpamplona.quartz.buzz.agentProfiles.AgentProfileEvent import com.vitorpamplona.quartz.buzz.amTurnMetrics.AgentTurnMetricEvent import com.vitorpamplona.quartz.buzz.aoObserver.ObserverFrameEvent import com.vitorpamplona.quartz.buzz.apPersonas.PersonaEvent +import com.vitorpamplona.quartz.buzz.arArtifacts.ArtifactEvent +import com.vitorpamplona.quartz.buzz.arArtifacts.ArtifactRemovalEvent import com.vitorpamplona.quartz.buzz.audit.AuditEntryEvent import com.vitorpamplona.quartz.buzz.cwChannelWindow.ThreadSummaryEvent +import com.vitorpamplona.quartz.buzz.cwChannelWindow.ThreadWindowBoundsEvent import com.vitorpamplona.quartz.buzz.cwChannelWindow.WindowBoundsEvent import com.vitorpamplona.quartz.buzz.dm.DmAddMemberEvent import com.vitorpamplona.quartz.buzz.dm.DmCreatedEvent @@ -41,6 +44,7 @@ import com.vitorpamplona.quartz.buzz.forum.ForumPostEvent import com.vitorpamplona.quartz.buzz.forum.ForumVoteEvent import com.vitorpamplona.quartz.buzz.huddles.HuddleEndedEvent import com.vitorpamplona.quartz.buzz.huddles.HuddleGuidelinesEvent +import com.vitorpamplona.quartz.buzz.huddles.HuddleLivenessEvent import com.vitorpamplona.quartz.buzz.huddles.HuddleParticipantJoinedEvent import com.vitorpamplona.quartz.buzz.huddles.HuddleParticipantLeftEvent import com.vitorpamplona.quartz.buzz.huddles.HuddleReactionEvent @@ -62,6 +66,7 @@ import com.vitorpamplona.quartz.buzz.moderation.ModerationResolveReportEvent import com.vitorpamplona.quartz.buzz.moderation.ModerationTimeoutEvent import com.vitorpamplona.quartz.buzz.moderation.ModerationUntimeoutEvent import com.vitorpamplona.quartz.buzz.moderation.ProductFeedbackEvent +import com.vitorpamplona.quartz.buzz.mpProjects.ProjectEvent import com.vitorpamplona.quartz.buzz.notifications.MemberAddedNotificationEvent import com.vitorpamplona.quartz.buzz.notifications.MemberRemovedNotificationEvent import com.vitorpamplona.quartz.buzz.pairing.PairingEvent @@ -82,6 +87,7 @@ import com.vitorpamplona.quartz.buzz.stream.StreamReminderEvent import com.vitorpamplona.quartz.buzz.stream.SystemMessageEvent import com.vitorpamplona.quartz.buzz.stream.sidecars.ChannelSummaryEvent import com.vitorpamplona.quartz.buzz.stream.sidecars.PresenceSnapshotEvent +import com.vitorpamplona.quartz.buzz.teamCatalog.TeamCatalogEvent import com.vitorpamplona.quartz.buzz.teams.TeamEvent import com.vitorpamplona.quartz.buzz.workflow.ApprovalDenyEvent import com.vitorpamplona.quartz.buzz.workflow.ApprovalGrantEvent @@ -481,8 +487,11 @@ class EventFactory { AgentProfileEvent.KIND -> AgentProfileEvent(id, pubKey, createdAt, tags, content, sig) ObserverFrameEvent.KIND -> ObserverFrameEvent(id, pubKey, createdAt, tags, content, sig) PersonaEvent.KIND -> PersonaEvent(id, pubKey, createdAt, tags, content, sig) + ArtifactEvent.KIND -> ArtifactEvent(id, pubKey, createdAt, tags, content, sig) + ArtifactRemovalEvent.KIND -> ArtifactRemovalEvent(id, pubKey, createdAt, tags, content, sig) AuditEntryEvent.KIND -> AuditEntryEvent(id, pubKey, createdAt, tags, content, sig) WindowBoundsEvent.KIND -> WindowBoundsEvent(id, pubKey, createdAt, tags, content, sig) + ThreadWindowBoundsEvent.KIND -> ThreadWindowBoundsEvent(id, pubKey, createdAt, tags, content, sig) DmAddMemberEvent.KIND -> DmAddMemberEvent(id, pubKey, createdAt, tags, content, sig) DmCreatedEvent.KIND -> DmCreatedEvent(id, pubKey, createdAt, tags, content, sig) DmHideEvent.KIND -> DmHideEvent(id, pubKey, createdAt, tags, content, sig) @@ -494,6 +503,7 @@ class EventFactory { ForumVoteEvent.KIND -> ForumVoteEvent(id, pubKey, createdAt, tags, content, sig) HuddleEndedEvent.KIND -> HuddleEndedEvent(id, pubKey, createdAt, tags, content, sig) HuddleGuidelinesEvent.KIND -> HuddleGuidelinesEvent(id, pubKey, createdAt, tags, content, sig) + HuddleLivenessEvent.KIND -> HuddleLivenessEvent(id, pubKey, createdAt, tags, content, sig) HuddleParticipantJoinedEvent.KIND -> HuddleParticipantJoinedEvent(id, pubKey, createdAt, tags, content, sig) HuddleParticipantLeftEvent.KIND -> HuddleParticipantLeftEvent(id, pubKey, createdAt, tags, content, sig) HuddleReactionEvent.KIND -> HuddleReactionEvent(id, pubKey, createdAt, tags, content, sig) @@ -515,6 +525,7 @@ class EventFactory { ModerationTimeoutEvent.KIND -> ModerationTimeoutEvent(id, pubKey, createdAt, tags, content, sig) ModerationUntimeoutEvent.KIND -> ModerationUntimeoutEvent(id, pubKey, createdAt, tags, content, sig) ProductFeedbackEvent.KIND -> ProductFeedbackEvent(id, pubKey, createdAt, tags, content, sig) + ProjectEvent.KIND -> ProjectEvent(id, pubKey, createdAt, tags, content, sig) MemberAddedNotificationEvent.KIND -> MemberAddedNotificationEvent(id, pubKey, createdAt, tags, content, sig) MemberRemovedNotificationEvent.KIND -> MemberRemovedNotificationEvent(id, pubKey, createdAt, tags, content, sig) PairingEvent.KIND -> PairingEvent(id, pubKey, createdAt, tags, content, sig) @@ -535,6 +546,7 @@ class EventFactory { ChannelSummaryEvent.KIND -> ChannelSummaryEvent(id, pubKey, createdAt, tags, content, sig) PresenceSnapshotEvent.KIND -> PresenceSnapshotEvent(id, pubKey, createdAt, tags, content, sig) TeamEvent.KIND -> TeamEvent(id, pubKey, createdAt, tags, content, sig) + TeamCatalogEvent.KIND -> TeamCatalogEvent(id, pubKey, createdAt, tags, content, sig) ApprovalDenyEvent.KIND -> ApprovalDenyEvent(id, pubKey, createdAt, tags, content, sig) ApprovalGrantEvent.KIND -> ApprovalGrantEvent(id, pubKey, createdAt, tags, content, sig) WorkflowApprovalDeniedEvent.KIND -> WorkflowApprovalDeniedEvent(id, pubKey, createdAt, tags, content, sig) diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/buzz/amTurnMetrics/AgentTurnMetricPayloadTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/buzz/amTurnMetrics/AgentTurnMetricPayloadTest.kt new file mode 100644 index 0000000000..2548535940 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/buzz/amTurnMetrics/AgentTurnMetricPayloadTest.kt @@ -0,0 +1,88 @@ +/* + * 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.buzz.amTurnMetrics + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class AgentTurnMetricPayloadTest { + @Test + fun decodesPricingIdentityWithCamelCaseFields() { + val payload = + AgentTurnMetricPayload.decodeFromJson( + """{"harness":"goose","timestamp":"2026-09-01T00:00:00Z","model":"sonnet",""" + + """"pricingIdentity":{"authority":"api.anthropic.com","model":"claude-sonnet-4-5","cacheClass":"ephemeral"}}""", + ) + val identity = payload.pricingIdentity!! + assertEquals("api.anthropic.com", identity.authority) + assertEquals("claude-sonnet-4-5", identity.model) + assertEquals("ephemeral", identity.cacheClass) + assertTrue(identity.isRegisteredAuthority()) + // The session model keeps its own, non-billing meaning. + assertEquals("sonnet", payload.model) + } + + @Test + fun omittedPricingIdentityMeansPriceUnknown() { + val payload = AgentTurnMetricPayload.decodeFromJson("""{"harness":"goose","timestamp":"t"}""") + assertNull(payload.pricingIdentity) + // Never serialized as null either. + assertFalse(payload.encodeToJson().contains("pricingIdentity")) + } + + @Test + fun cacheClassIsOmittedNotNullWhenAbsent() { + val payload = AgentTurnMetricPayload(harness = "h", timestamp = "t", pricingIdentity = PricingIdentity("openrouter.ai", "x/y")) + val json = payload.encodeToJson() + assertTrue(json.contains(""""pricingIdentity":{"authority":"openrouter.ai","model":"x/y"}"""), json) + assertEquals(payload, AgentTurnMetricPayload.decodeFromJson(json)) + } + + /** Like serde on the Rust side: a present identity without its required strings fails the payload. */ + @Test + fun pricingIdentityRequiresAuthorityAndModel() { + assertFailsWith { + AgentTurnMetricPayload.decodeFromJson("""{"harness":"h","timestamp":"t","pricingIdentity":{"model":"m"}}""") + } + } + + /** The Rust parser accepts any authority string; only the registered set is priceable. */ + @Test + fun unregisteredAuthorityParsesButIsFlagged() { + val identity = PricingIdentity("https://api.anthropic.com/", "m") + assertFalse(identity.isRegisteredAuthority()) + assertTrue(PricingIdentity("api.openai.com", "gpt").isRegisteredAuthority()) + } + + @Test + fun explicitZeroCacheIsKeptAndOmittedIsNull() { + val payload = + AgentTurnMetricPayload.decodeFromJson( + """{"harness":"h","timestamp":"t","turn":{"inputTokens":10,"cacheReadTokens":0}}""", + ) + assertEquals(0L, payload.turn?.cacheReadTokens) + assertNull(payload.turn?.cacheWriteTokens) + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/buzz/apPersonas/PersonaEventTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/buzz/apPersonas/PersonaEventTest.kt index 366114a465..0896357681 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/buzz/apPersonas/PersonaEventTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/buzz/apPersonas/PersonaEventTest.kt @@ -22,6 +22,7 @@ package com.vitorpamplona.quartz.buzz.apPersonas import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertFalse import kotlin.test.assertNull import kotlin.test.assertTrue @@ -100,4 +101,77 @@ class PersonaEventTest { ) assertNull(event.personaOrNull()) } + + // NIP-AP reference vector (docs/nips/NIP-AP.md): the content bytes are pinned, because they + // fix the event id and the desktop's persona content hash. + @Test + fun contentMatchesNipApVectorByteForByte() { + val persona = + PersonaContent( + displayName = "Test Agent", + systemPrompt = "You are a test assistant.", + avatarUrl = "https://example.com/avatar.png", + runtime = "goose", + model = "claude-opus-4", + provider = "anthropic", + namePool = listOf("Alpha", "Beta"), + ) + assertEquals( + """{"display_name":"Test Agent","system_prompt":"You are a test assistant.","avatar_url":"https://example.com/avatar.png","runtime":"goose","model":"claude-opus-4","provider":"anthropic","name_pool":["Alpha","Beta"]}""", + persona.encodeToJson(), + ) + } + + @Test + fun newFieldsKeepUpstreamOrderAndRoundTrip() { + val json = + """{"display_name":"A","system_prompt":"p","acp_command":"buzz-claude-acp","avatar_url":"u","parallelism":2,"description":"d","session_policy":"thread"}""" + val persona = PersonaContent.decodeFromJson(json) + assertEquals("buzz-claude-acp", persona.acpCommand) + assertEquals("d", persona.description) + assertTrue(persona.isThreadSessionPolicy()) + // Re-encoding is byte-identical, so an edit that changes nothing keeps the content hash. + assertEquals(json, persona.encodeToJson()) + } + + @Test + fun sharedTagIsWrittenAndRead() { + val persona = PersonaContent(displayName = "A", acpCommand = "buzz-goose-acp") + val shared = PersonaEvent.build(persona, slug = "a", shared = true) + assertEquals(listOf("shared", "true"), shared.tags.single { it[0] == "shared" }.toList()) + assertTrue(PersonaEvent("00", "00", 0L, shared.tags, shared.content, "00").isShared()) + + val private = PersonaEvent.build(persona, slug = "a") + assertTrue(private.tags.none { it[0] == "shared" }) + assertFalse(PersonaEvent("00", "00", 0L, private.tags, private.content, "00").isShared()) + } + + @Test + fun malformedSharedTagsReadAsNotShared() { + fun shared(vararg tags: Array) = PersonaEvent("00", "00", 0L, arrayOf(arrayOf("d", "a"), *tags), "{}", "00").isShared() + + assertFalse(shared(arrayOf("shared", "false"))) + assertFalse(shared(arrayOf("shared", "true", "extra"))) + assertFalse(shared(arrayOf("shared"))) + assertFalse(shared(arrayOf("shared", "true"), arrayOf("shared", "true"))) + assertTrue(shared(arrayOf("shared", "true"))) + } + + @Test + fun sharedHeadPublishesPortableAcpCommandOnly() { + // Unset becomes the explicit stock harness; a portable alias stays; a local path is dropped. + assertEquals("buzz-acp", PersonaContent(displayName = "A").forSharedCatalog().acpCommand) + assertEquals("buzz-codex-acp", PersonaContent(displayName = "A", acpCommand = "buzz-codex-acp").forSharedCatalog().acpCommand) + assertNull(PersonaContent(displayName = "A", acpCommand = "/usr/local/bin/my-acp").forSharedCatalog().acpCommand) + + assertTrue(PersonaContent.isPortableAcpCommand("buzz-acp")) + assertFalse(PersonaContent.isPortableAcpCommand("buzz--acp")) + assertFalse(PersonaContent.isPortableAcpCommand("buzz-a b-acp")) + } + + @Test + fun editStampsAfterThePriorHead() { + val future = 4_000_000_000L + assertEquals(future + 1, PersonaEvent.build(PersonaContent(displayName = "A"), "a", priorHeadCreatedAt = future).createdAt) + } } diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/buzz/arArtifacts/ArtifactEventTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/buzz/arArtifacts/ArtifactEventTest.kt new file mode 100644 index 0000000000..820b384491 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/buzz/arArtifacts/ArtifactEventTest.kt @@ -0,0 +1,233 @@ +/* + * 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.buzz.arArtifacts + +import com.vitorpamplona.quartz.buzz.arArtifacts.tags.ArtifactOp +import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertIs +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class ArtifactEventTest { + private val artifactId = "04737c81-e5e8-4412-bb47-f446813cfeba" + private val home = "9b353519-f4fe-4757-aef4-bec6cc0ae54c" + private val other = "17cfe3ca-b3d0-4a76-a2a4-4eaf0d3a4109" + private val prev = "a".repeat(64) + + private fun EventTemplate.toEvent(id: String = "0".repeat(64)) = ArtifactEvent(id, "f".repeat(64), createdAt, tags, content, "sig") + + /** The same envelope as upstream's `event()` test helper in `buzz-core/src/artifact.rs`. */ + private fun envelope( + op: String, + extra: List> = emptyList(), + ): Array> { + val tags = + mutableListOf( + arrayOf("ar", "1"), + arrayOf("d", artifactId), + arrayOf("h", home), + arrayOf("type", "buzz.task"), + arrayOf("op", op), + ) + if (op != "delete") tags.add(arrayOf("title", "Title")) + if (op != "create") tags.add(arrayOf("prev", prev)) + tags.addAll(extra) + return tags.toTypedArray() + } + + private fun validate( + op: String, + extra: List> = emptyList(), + content: String = "", + ) = ArtifactValidator.validate(envelope(op, extra), content) + + @Test + fun lifecycleEnvelopesAreValid() { + for (op in listOf("create", "update", "move", "delete", "restore")) { + assertIs(validate(op), op) + } + // Client tags and non-JSON content are opaque to the envelope. + assertIs(validate("create", listOf(arrayOf("project", "opaque", "anything")), "not json")) + } + + @Test + fun malformedAndDeletePayloadsAreRejected() { + val cases = + listOf( + Triple(listOf(arrayOf("title", "duplicate")), "create", ""), + Triple(listOf(arrayOf("ar", "2")), "create", ""), + Triple(listOf(arrayOf("prev", "a".repeat(64))), "create", ""), + Triple(listOf(arrayOf("root", "A".repeat(64))), "create", ""), + Triple(listOf(arrayOf("project", "hidden")), "delete", ""), + Triple(listOf(arrayOf("title", "hidden")), "delete", ""), + Triple(emptyList(), "delete", "hidden"), + Triple(listOf(arrayOf("x", "x".repeat(4097))), "create", ""), + Triple(listOf(arrayOf("x".repeat(129), "x")), "create", ""), + ) + for ((extra, op, content) in cases) { + assertIs(validate(op, extra, content), "${extra.map { it.toList() }} $op") + } + } + + @Test + fun envelopeDetailRules() { + val base = envelope("create") + + fun replace( + name: String, + value: String, + ) = base.map { if (it[0] == name) arrayOf(name, value) else it }.toTypedArray() + + assertEquals(ArtifactValidation.Invalid("unsupported artifact envelope version"), ArtifactValidator.validate(replace("ar", "2"), "")) + assertIs(ArtifactValidator.validate(replace("d", artifactId.uppercase()), "")) + assertIs(ArtifactValidator.validate(replace("h", "00000000-0000-0000-0000-000000000000"), "")) + assertIs(ArtifactValidator.validate(replace("type", "task"), "")) + assertIs(ArtifactValidator.validate(replace("type", "buzz.Task"), "")) + assertIs(ArtifactValidator.validate(replace("type", "buzz..task"), "")) + assertIs(ArtifactValidator.validate(replace("type", "buzz.1task"), "")) + assertIs(ArtifactValidator.validate(replace("type", "acme.ops_board.v-2"), "")) + assertEquals(ArtifactValidation.Invalid("invalid artifact operation"), ArtifactValidator.validate(replace("op", "archive"), "")) + assertIs(ArtifactValidator.validate(replace("title", " "), "")) + assertIs(ArtifactValidator.validate(replace("title", "x".repeat(513)), "")) + assertIs(ArtifactValidator.validate(replace("title", "x".repeat(512)), "")) + // A three-element envelope tag is rejected even when the extra element is empty. + assertIs(ArtifactValidator.validate(base.map { if (it[0] == "title") arrayOf("title", "T", "") else it }.toTypedArray(), "")) + // Missing envelope tag. + assertEquals(ArtifactValidation.Invalid("missing required envelope tag"), ArtifactValidator.validate(base.filter { it[0] != "type" }.toTypedArray(), "")) + // Too many tags. + val tooMany = (base.toList() + List(ArtifactValidator.MAX_TAGS) { arrayOf("x", "$it") }).toTypedArray() + assertEquals(ArtifactValidation.Invalid("too many tags"), ArtifactValidator.validate(tooMany, "")) + // A delete may carry a NIP-OA auth tag. + assertIs(validate("delete", listOf(arrayOf("auth", "b".repeat(64), "", "c".repeat(128))))) + } + + @Test + fun canonicalIdentifiers() { + assertFalse(ArtifactIds.isCanonicalUuid("00000000-0000-0000-0000-000000000000")) + assertFalse(ArtifactIds.isCanonicalUuid(artifactId.replace("-", ""))) + assertFalse(ArtifactIds.isCanonicalUuid(artifactId.uppercase())) + assertTrue(ArtifactIds.isCanonicalUuid(artifactId)) + assertTrue(ArtifactIds.isEventId("f".repeat(64))) + assertFalse(ArtifactIds.isEventId("F".repeat(64))) + assertFalse(ArtifactIds.isEventId("f".repeat(63))) + } + + @Test + fun createRoundTrips() { + val tpl = + ArtifactEvent.create(artifactId, home, "buzz.task", "Fix the payment timeout", "{\"version\":1,\"status\":\"open\"}") { + add(arrayOf("assignee", "b".repeat(64))) + add(arrayOf("project", other)) + } + val ev = tpl.toEvent() + + assertEquals(45010, ev.kind) + assertEquals( + listOf("ar", "d", "h", "type", "title", "op", "assignee", "project"), + ev.tags.map { it[0] }, + ) + assertEquals(artifactId, ev.artifactId()) + assertEquals(home, ev.home()) + assertEquals("buzz.task", ev.type()) + assertEquals("Fix the payment timeout", ev.title()) + assertEquals(ArtifactOp.CREATE, ev.op()) + assertNull(ev.prev()) + assertNull(ev.root()) + assertEquals(listOf("assignee", "project"), ev.clientTags().map { it[0] }) + + val envelope = ev.envelopeOrNull()!! + assertEquals(ArtifactEnvelope(artifactId, home, "buzz.task", ArtifactOp.CREATE, null, null), envelope) + } + + @Test + fun updateCarriesEnvelopeAndClientTagsForward() { + val root = "c".repeat(64) + val created = + ArtifactEvent + .create(artifactId, home, "buzz.task", "Title", "v1", root) { + add(arrayOf("project", other)) + }.toEvent("1".repeat(64)) + + val updated = ArtifactEvent.update(created, "Renamed", "v2").toEvent("2".repeat(64)) + assertEquals(ArtifactOp.UPDATE, updated.op()) + assertEquals(created.id, updated.prev()) + assertEquals(root, updated.root()) + assertEquals(home, updated.home()) + assertEquals("Renamed", updated.title()) + assertEquals(listOf(other), updated.clientTags().map { it[1] }) + assertTrue(updated.isWellFormed()) + + val dropped = ArtifactEvent.update(created, "Renamed", "v2", keepClientTags = false).toEvent() + assertTrue(dropped.clientTags().isEmpty()) + } + + @Test + fun deletePreservesTypeHomeAndRootAndStripsEverythingElse() { + val root = "c".repeat(64) + val current = + ArtifactEvent + .create(artifactId, home, "buzz.task", "Title", "payload", root) { + add(arrayOf("assignee", "b".repeat(64))) + }.toEvent("1".repeat(64)) + + val deletion = ArtifactEvent.delete(current).toEvent("2".repeat(64)) + assertEquals(ArtifactOp.DELETE, deletion.op()) + assertTrue(deletion.isDelete()) + assertEquals("", deletion.content) + assertNull(deletion.title()) + assertEquals(root, deletion.root()) + assertEquals("buzz.task", deletion.type()) + assertEquals(current.id, deletion.prev()) + assertTrue(deletion.clientTags().isEmpty()) + assertTrue(deletion.isWellFormed()) + } + + @Test + fun buildRefusesWhatTheRelayWouldReject() { + assertFailsWith { ArtifactEvent.build(artifactId.uppercase(), home, "buzz.task", ArtifactOp.CREATE, "T", "") } + assertFailsWith { ArtifactEvent.build(artifactId, home, "task", ArtifactOp.CREATE, "T", "") } + assertFailsWith { ArtifactEvent.build(artifactId, home, "buzz.task", ArtifactOp.CREATE, " ", "") } + assertFailsWith { ArtifactEvent.build(artifactId, home, "buzz.task", ArtifactOp.CREATE, "T", "", prev = prev) } + assertFailsWith { ArtifactEvent.build(artifactId, home, "buzz.task", ArtifactOp.UPDATE, "T", "") } + assertFailsWith { ArtifactEvent.build(artifactId, home, "buzz.task", ArtifactOp.DELETE, "T", "", prev = prev) } + assertFailsWith { ArtifactEvent.build(artifactId, home, "buzz.task", ArtifactOp.DELETE, null, "x", prev = prev) } + assertFailsWith { ArtifactEvent.build(artifactId, home, "buzz.task", ArtifactOp.CREATE, "T", "", root = "A".repeat(64)) } + // Client tags added by the initializer are validated too. + assertFailsWith { + ArtifactEvent.build(artifactId, home, "buzz.task", ArtifactOp.CREATE, "T", "") { add(arrayOf("x", "x".repeat(4097))) } + } + assertFailsWith { + ArtifactEvent.build(artifactId, home, "buzz.task", ArtifactOp.DELETE, null, "", prev = prev) { add(arrayOf("project", other)) } + } + } + + @Test + fun moveBuildsIntoANewHome() { + val ev = ArtifactEvent.build(artifactId, other, "buzz.task", ArtifactOp.MOVE, "Title", "snapshot", prev = prev).toEvent() + assertEquals(other, ev.home()) + assertEquals(ArtifactOp.MOVE, ev.op()) + assertTrue(ev.isWellFormed()) + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/buzz/arArtifacts/ArtifactHeadResolverTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/buzz/arArtifacts/ArtifactHeadResolverTest.kt new file mode 100644 index 0000000000..f29f11afc7 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/buzz/arArtifacts/ArtifactHeadResolverTest.kt @@ -0,0 +1,158 @@ +/* + * 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.buzz.arArtifacts + +import com.vitorpamplona.quartz.buzz.arArtifacts.tags.ArtifactOp +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertTrue + +class ArtifactHeadResolverTest { + private val artifactId = "04737c81-e5e8-4412-bb47-f446813cfeba" + private val home = "9b353519-f4fe-4757-aef4-bec6cc0ae54c" + private val destination = "17cfe3ca-b3d0-4a76-a2a4-4eaf0d3a4109" + + private fun id(n: Int) = n.toString().padStart(64, '0') + + private fun revision( + n: Int, + op: ArtifactOp, + prev: Int?, + createdAt: Long = 1000L + n, + channel: String = home, + d: String = artifactId, + ): ArtifactEvent { + val title = if (op == ArtifactOp.DELETE) null else "T$n" + val tpl = ArtifactEvent.build(d, channel, "buzz.task", op, title, if (op == ArtifactOp.DELETE) "" else "c$n", prev?.let { id(it) }, createdAt = createdAt) + return ArtifactEvent(id(n), "f".repeat(64), tpl.createdAt, tpl.tags, tpl.content, "sig") + } + + @Test + fun emptyIsUnknown() { + assertEquals(ArtifactHead.Unknown, ArtifactHeadResolver.resolve(artifactId, emptyList())) + } + + @Test + fun linearChainResolvesToTheTipRegardlessOfTimestampsOrOrder() { + val create = revision(1, ArtifactOp.CREATE, null, createdAt = 5000) + val update = revision(2, ArtifactOp.UPDATE, 1, createdAt = 10) + val update2 = revision(3, ArtifactOp.UPDATE, 2, createdAt = 20) + + val head = ArtifactHeadResolver.resolve(artifactId, listOf(update2, create, update, update)) + assertIs(head) + assertEquals(id(3), head.revision.id) + assertTrue(head.chainComplete) + + assertEquals(listOf(id(3), id(2), id(1)), ArtifactHeadResolver.history(head.revision, listOf(create, update, update2)).map { it.id }) + } + + @Test + fun otherIdentitiesAndMalformedRevisionsAreIgnored() { + val create = revision(1, ArtifactOp.CREATE, null) + val foreign = revision(2, ArtifactOp.UPDATE, 1, d = "5b0d1c1e-8a7e-4c1a-9d3b-2f1e0a9b8c7d") + val head = ArtifactHeadResolver.resolve(artifactId, listOf(create, foreign)) + assertIs(head) + assertEquals(id(1), head.revision.id) + } + + @Test + fun aForkIsAmbiguousNotResolvedByTimestamp() { + val create = revision(1, ArtifactOp.CREATE, null) + val x = revision(2, ArtifactOp.UPDATE, 1, createdAt = 2000) + val y = revision(3, ArtifactOp.UPDATE, 1, createdAt = 3000) + val head = ArtifactHeadResolver.resolve(artifactId, listOf(create, x, y)) + assertIs(head) + assertEquals(setOf(id(2), id(3)), head.candidates.map { it.id }.toSet()) + } + + @Test + fun aRedactedMiddleRevisionStillResolvesAcrossTheGap() { + // 1 <- 2 <- (3 redacted) <- 4: tips are 2 and 4; the create-rooted segment is older. + val head = + ArtifactHeadResolver.resolve( + artifactId, + listOf(revision(1, ArtifactOp.CREATE, null), revision(2, ArtifactOp.UPDATE, 1), revision(4, ArtifactOp.UPDATE, 3)), + ) + assertIs(head) + assertEquals(id(4), head.revision.id) + assertEquals(false, head.chainComplete) + } + + @Test + fun twoGapsAreAmbiguous() { + val head = + ArtifactHeadResolver.resolve( + artifactId, + listOf(revision(1, ArtifactOp.CREATE, null), revision(4, ArtifactOp.UPDATE, 3), revision(6, ArtifactOp.UPDATE, 5)), + ) + assertIs(head) + } + + @Test + fun destinationReaderSeesOnlyTheMoveAndLater() { + val move = revision(3, ArtifactOp.MOVE, 2, channel = destination) + val update = revision(4, ArtifactOp.UPDATE, 3, channel = destination) + val head = ArtifactHeadResolver.resolve(artifactId, listOf(update, move)) + assertIs(head) + assertEquals(id(4), head.revision.id) + assertEquals(false, head.chainComplete) + } + + @Test + fun sourceReaderSeesTheRemoval() { + val create = revision(1, ArtifactOp.CREATE, null) + val update = revision(2, ArtifactOp.UPDATE, 1) + val removalTpl = ArtifactRemovalEvent.build(artifactId, home, id(2)) + val removal = ArtifactRemovalEvent(id(9), "e".repeat(64), removalTpl.createdAt, removalTpl.tags, removalTpl.content, "sig") + + val head = ArtifactHeadResolver.resolve(artifactId, listOf(create, update), listOf(removal)) + assertIs(head) + assertEquals(id(2), head.lastRevision.id) + + // A reader of both channels sees the move revision, which supersedes the removal's prev. + val move = revision(3, ArtifactOp.MOVE, 2, channel = destination) + val both = ArtifactHeadResolver.resolve(artifactId, listOf(create, update, move), listOf(removal)) + assertIs(both) + assertEquals(id(3), both.revision.id) + } + + @Test + fun deletedHeadIsReportedAsDeletedAndRestoreRevivesIt() { + val create = revision(1, ArtifactOp.CREATE, null) + val delete = revision(2, ArtifactOp.DELETE, 1) + val deleted = ArtifactHeadResolver.resolve(artifactId, listOf(create, delete)) + assertIs(deleted) + assertTrue(deleted.isDeleted) + + val restore = revision(3, ArtifactOp.RESTORE, 2) + val restored = ArtifactHeadResolver.resolve(artifactId, listOf(create, delete, restore)) + assertIs(restored) + assertEquals(false, restored.isDeleted) + } + + @Test + fun aPrevCycleIsAmbiguous() { + val head = ArtifactHeadResolver.resolve(artifactId, listOf(revision(2, ArtifactOp.UPDATE, 3), revision(3, ArtifactOp.UPDATE, 2))) + assertIs(head) + assertTrue(head.candidates.isEmpty()) + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/buzz/arArtifacts/ArtifactRemovalEventTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/buzz/arArtifacts/ArtifactRemovalEventTest.kt new file mode 100644 index 0000000000..422b84ded8 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/buzz/arArtifacts/ArtifactRemovalEventTest.kt @@ -0,0 +1,68 @@ +/* + * 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.buzz.arArtifacts + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class ArtifactRemovalEventTest { + private val artifactId = "04737c81-e5e8-4412-bb47-f446813cfeba" + private val source = "9b353519-f4fe-4757-aef4-bec6cc0ae54c" + private val replaced = "a".repeat(64) + + @Test + fun relayShapedRemovalParses() { + // Exactly the tag list `removal_marker` in buzz-db/src/store/artifact.rs emits. + val tags = + arrayOf( + arrayOf("ar", "1"), + arrayOf("d", artifactId), + arrayOf("h", source), + arrayOf("reason", "moved"), + arrayOf("prev", replaced), + ) + val ev = ArtifactRemovalEvent("0".repeat(64), "e".repeat(64), 1, tags, "", "sig") + + assertEquals(45011, ev.kind) + assertEquals(artifactId, ev.artifactId()) + assertEquals(source, ev.sourceChannel()) + assertEquals("moved", ev.reason()) + assertEquals(replaced, ev.replacedRevision()) + assertTrue(ev.isWellFormed()) + } + + @Test + fun buildMatchesTheRelayTagOrder() { + val tpl = ArtifactRemovalEvent.build(artifactId, source, replaced) + assertEquals(listOf("ar", "d", "h", "reason", "prev"), tpl.tags.map { it[0] }) + assertEquals("", tpl.content) + } + + @Test + fun malformedRemovalsAreFlagged() { + val tpl = ArtifactRemovalEvent.build(artifactId, source, replaced) + assertFalse(ArtifactRemovalEvent("0".repeat(64), "e".repeat(64), 1, tpl.tags, "content", "sig").isWellFormed()) + val badPrev = tpl.tags.map { if (it[0] == "prev") arrayOf("prev", "A".repeat(64)) else it }.toTypedArray() + assertFalse(ArtifactRemovalEvent("0".repeat(64), "e".repeat(64), 1, badPrev, "", "sig").isWellFormed()) + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/buzz/cwChannelWindow/ThreadWindowBoundsEventTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/buzz/cwChannelWindow/ThreadWindowBoundsEventTest.kt new file mode 100644 index 0000000000..5599d8def1 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/buzz/cwChannelWindow/ThreadWindowBoundsEventTest.kt @@ -0,0 +1,128 @@ +/* + * 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.buzz.cwChannelWindow + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertNotEquals +import kotlin.test.assertTrue + +class ThreadWindowBoundsEventTest { + private val nilChannel = "00000000-0000-0000-0000-000000000000" + private val root = "ab".repeat(32) + private val reader = "ab".repeat(32) + private val host = "relay.example" + + @Test + fun bindingMatchesUpstreamKnownAnswer() { + // `binding_normalizes_defaults_and_binds_every_argument` in buzz-core/src/thread_window.rs. + val request = ThreadWindowRequest(nilChannel, root, kinds = listOf(9)) + assertEquals("tw:1:5252322dfd797ddb1d5f1150acd4cf914b9fc09e39bcf3048d25aed514b3546d", request.binding(host, reader)) + + // Explicit defaults and an uppercase root normalize to the same binding. + assertEquals(request.binding(host, reader), ThreadWindowRequest(nilChannel, root.uppercase(), listOf(9, 9), limit = 50, depth = 100, includeAux = false).binding(host, reader)) + + // Every argument is bound. + val base = request.binding(host, reader) + for (changed in listOf( + request.copy(limit = 49), + request.copy(depth = 1), + request.copy(includeAux = true), + request.copy(kinds = listOf(40002)), + request.copy(rootId = "cd".repeat(32)), + request.copy(channelId = "9b353519-f4fe-4757-aef4-bec6cc0ae54c"), + request.copy(cursor = NextCursor(0, "ab".repeat(32))), + )) { + assertNotEquals(base, changed.binding(host, reader), changed.toString()) + } + assertNotEquals(base, request.binding("other.example", reader)) + assertNotEquals(base, request.binding(host, "cd".repeat(32))) + } + + @Test + fun requestRejectsWhatTheRelayRejects() { + assertFailsWith { ThreadWindowRequest(nilChannel, root, kinds = listOf(7)) } + assertFailsWith { ThreadWindowRequest(nilChannel, root, kinds = emptyList()) } + assertFailsWith { ThreadWindowRequest(nilChannel, root, listOf(9), limit = 0) } + assertFailsWith { ThreadWindowRequest(nilChannel, root, listOf(9), limit = 201) } + assertFailsWith { ThreadWindowRequest(nilChannel, root, listOf(9), depth = 101) } + assertFailsWith { ThreadWindowRequest(nilChannel, "bad", listOf(9)) } + } + + @Test + fun relayShapedOverlayParsesAndMatches() { + val request = ThreadWindowRequest(nilChannel, root, kinds = listOf(9)) + val tags = arrayOf(arrayOf("d", request.binding(host, reader)), arrayOf("h", nilChannel), arrayOf("e", root)) + // serde_json's `json!` sorts keys, so the relay's body arrives alphabetized. + val content = """{"direction":"older","has_more":true,"next_cursor":{"created_at":1751500000,"id":"${"c".repeat(64)}"},"version":1}""" + val ev = ThreadWindowBoundsEvent("0".repeat(64), "e".repeat(64), 1, tags, content, "sig") + + assertEquals(39007, ev.kind) + assertEquals(nilChannel, ev.channelId()) + assertEquals(root, ev.rootId()) + assertTrue(ev.bounds().hasMore) + assertEquals(1751500000L, ev.bounds().nextCursor?.createdAt) + assertTrue(ev.isWellFormed()) + assertTrue(ev.matches(request, host, reader)) + assertFalse(ev.matches(request.copy(limit = 10), host, reader)) + assertFalse(ev.matches(request, "other.example", reader)) + } + + @Test + fun exhaustedPageRoundTrips() { + val request = ThreadWindowRequest(nilChannel, root, kinds = listOf(40002, 9), includeAux = true) + val tpl = ThreadWindowBoundsEvent.build(request, host, reader, ThreadWindowBoundsContent(hasMore = false)) + assertTrue(tpl.content.contains("\"next_cursor\":null"), tpl.content) + val ev = ThreadWindowBoundsEvent("0".repeat(64), "e".repeat(64), tpl.createdAt, tpl.tags, tpl.content, "sig") + assertEquals(listOf("d", "h", "e"), ev.tags.map { it[0] }) + assertTrue(ev.isWellFormed()) + assertTrue(ev.matches(request, host, reader)) + } + + @Test + fun malformedOverlaysAreRejected() { + val request = ThreadWindowRequest(nilChannel, root, kinds = listOf(9)) + val d = arrayOf("d", request.binding(host, reader)) + val h = arrayOf("h", nilChannel) + val e = arrayOf("e", root) + val good = """{"version":1,"direction":"older","has_more":false,"next_cursor":null}""" + + fun ev( + tags: Array>, + content: String = good, + ) = ThreadWindowBoundsEvent("0".repeat(64), "e".repeat(64), 1, tags, content, "sig") + + assertTrue(ev(arrayOf(d, h, e)).isWellFormed()) + assertFalse(ev(arrayOf(d, h)).isWellFormed()) + assertFalse(ev(arrayOf(d, h, e, arrayOf("p", reader))).isWellFormed()) + assertFalse(ev(arrayOf(d, h, arrayOf("e", root, "wss://relay"))).isWellFormed()) + assertFalse(ev(arrayOf(arrayOf("d", "$nilChannel:head"), h, e)).isWellFormed()) + assertFalse(ev(arrayOf(d, arrayOf("h", nilChannel.uppercase().replace('0', 'A')), e)).isWellFormed()) + assertFalse(ev(arrayOf(d, h, arrayOf("e", root.uppercase()))).isWellFormed()) + // has_more without a cursor, a cursor without has_more, wrong version or direction. + assertFalse(ev(arrayOf(d, h, e), """{"version":1,"direction":"older","has_more":true,"next_cursor":null}""").isWellFormed()) + assertFalse(ev(arrayOf(d, h, e), """{"version":1,"direction":"older","has_more":false,"next_cursor":{"created_at":1,"id":"$root"}}""").isWellFormed()) + assertFalse(ev(arrayOf(d, h, e), """{"version":2,"direction":"older","has_more":false,"next_cursor":null}""").isWellFormed()) + assertFalse(ev(arrayOf(d, h, e), """{"version":1,"direction":"newer","has_more":false,"next_cursor":null}""").isWellFormed()) + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/buzz/huddles/HuddleLivenessEventTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/buzz/huddles/HuddleLivenessEventTest.kt new file mode 100644 index 0000000000..cd223ddd38 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/buzz/huddles/HuddleLivenessEventTest.kt @@ -0,0 +1,94 @@ +/* + * 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.buzz.huddles + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class HuddleLivenessEventTest { + private val session = "4e8a7c2e-3b1f-4d6a-9a1e-7c5b2d9f0e11" + private val channel = "9b353519-f4fe-4757-aef4-bec6cc0ae54c" + + @Test + fun relayShapedSnapshotParses() { + // The exact shape `handle_huddle_liveness_req` signs: d, h, and a string generation. + val tags = arrayOf(arrayOf("d", session), arrayOf("h", channel)) + val content = """{"ephemeral_channel_id":"$session","generation":"18446744073709551615"}""" + val ev = HuddleLivenessEvent("0".repeat(64), "e".repeat(64), 1, tags, content, "sig") + + assertEquals(48104, ev.kind) + assertEquals(session, ev.sessionId()) + assertEquals(channel, ev.channelId()) + assertEquals(session, ev.liveness()?.ephemeralChannelId) + assertEquals("18446744073709551615", ev.generation()) + } + + @Test + fun malformedContentIsNotAuthoritative() { + val tags = arrayOf(arrayOf("d", session), arrayOf("h", channel)) + assertNull(HuddleLivenessEvent("0".repeat(64), "e".repeat(64), 1, tags, "{", "sig").generation()) + assertNull(HuddleLivenessEvent("0".repeat(64), "e".repeat(64), 1, tags, """{"ephemeral_channel_id":"$session","generation":7}""", "sig").generation()) + assertNull(HuddleLivenessEvent("0".repeat(64), "e".repeat(64), 1, tags, """{"ephemeral_channel_id":"$session","generation":""}""", "sig").generation()) + } + + @Test + fun buildRoundTrips() { + val tpl = HuddleLivenessEvent.build(session, channel, "42") + assertEquals(listOf("d", "h"), tpl.tags.map { it[0] }) + val ev = HuddleLivenessEvent("0".repeat(64), "e".repeat(64), tpl.createdAt, tpl.tags, tpl.content, "sig") + assertEquals(session, ev.sessionId()) + assertEquals(channel, ev.channelId()) + assertEquals("42", ev.generation()) + } + + @Test + fun filterIsLivenessOnly() { + val filter = HuddleLivenessEvent.filter(listOf(channel), listOf(session)) + assertEquals(listOf(48104), filter.kinds) + assertEquals(mapOf("h" to listOf(channel), "d" to listOf(session)), filter.tags) + assertEquals(1, filter.limit) + + val json = filter.toJson() + assertTrue(json.contains("\"kinds\":[48104]"), json) + assertTrue(json.contains("\"#h\":[\"$channel\"]"), json) + assertTrue(json.contains("\"#d\":[\"$session\"]"), json) + + val allSessions = HuddleLivenessEvent.filter(listOf(channel)) + assertEquals(mapOf("h" to listOf(channel)), allSessions.tags) + assertNull(allSessions.limit) + + assertFailsWith { HuddleLivenessEvent.filter(emptyList()) } + assertFailsWith { HuddleLivenessEvent.filter(List(129) { channel }) } + } + + @Test + fun generationsCompareOnlyWhenBothAreDecimal() { + assertEquals(-1, HuddleLivenessContent.compareGenerations("9", "10")) + assertEquals(1, HuddleLivenessContent.compareGenerations("18446744073709551615", "18446744073709551614")) + assertEquals(0, HuddleLivenessContent.compareGenerations("007", "7")) + assertNull(HuddleLivenessContent.compareGenerations("epoch-a", "3")) + assertNull(HuddleLivenessContent.compareGenerations("", "3")) + assertNull(HuddleLivenessContent.compareGenerations("-1", "3")) + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/buzz/invite/BuzzInviteLinkTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/buzz/invite/BuzzInviteLinkTest.kt index 4583b09b99..5f9b54a2e7 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/buzz/invite/BuzzInviteLinkTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/buzz/invite/BuzzInviteLinkTest.kt @@ -22,6 +22,7 @@ package com.vitorpamplona.quartz.buzz.invite import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertFalse import kotlin.test.assertNull import kotlin.test.assertTrue @@ -105,4 +106,41 @@ class BuzzInviteLinkTest { // `v2` without the dot is a dotless token like any other. assertNull(BuzzInviteLink.parse("https://host.example/invite/v2")) } + + /** Exactly what `validate_v2_code` in `buzz-core/src/invite.rs` accepts — the relay never falls back to v1 for `v2.`. */ + @Test + fun v2CodesMustBeCanonicalBase64UrlOfThirtyTwoBytes() { + assertTrue(BuzzInviteLink.isValidV2Code(v2Token)) + + val secret = v2Token.removePrefix("v2.") + listOf( + // Upstream's own malformed cases. + "v2.", + "v2.not-base64!", + "v2.${secret.dropLast(2)}", // 31 bytes' worth + "$v2Token=", // padded alias + // Same 32 bytes, but the unused low bits of the last symbol are set: a non-canonical alias. + "v2.${secret.dropLast(1)}5", + // Standard (not url-safe) alphabet, and extra segments. + "v2.${secret.replace('_', '/')}+", + "$v2Token.sig", + // 33 bytes. + "v2.${secret}AA", + ).forEach { code -> + assertFalse(BuzzInviteLink.isValidV2Code(code), "accepted malformed v2 code: $code") + assertNull(BuzzInviteLink.parse("https://host.example/invite/$code"), "parsed malformed v2 invite: $code") + } + } + + @Test + fun claimResponseSlugsAreRead() { + assertEquals(BuzzInviteClaim.ERROR_EXHAUSTED, BuzzInviteClaim.errorOf("""{"error":"invite_exhausted"}""")) + assertEquals(BuzzInviteClaim.ERROR_EXPIRED, BuzzInviteClaim.errorOf("""{"error":"invite_expired"}""")) + assertEquals( + BuzzInviteClaim.STATUS_ALREADY_MEMBER, + BuzzInviteClaim.statusOf("""{"status":"already_member","community_id":"c","host":"h","role":"member"}"""), + ) + assertNull(BuzzInviteClaim.errorOf("bad gateway")) + assertNull(BuzzInviteClaim.statusOf("""{"error":"invite_invalid"}""")) + } } diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/buzz/mpProjects/ProjectEventTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/buzz/mpProjects/ProjectEventTest.kt new file mode 100644 index 0000000000..741caa59d0 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/buzz/mpProjects/ProjectEventTest.kt @@ -0,0 +1,162 @@ +/* + * 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.buzz.mpProjects + +import com.vitorpamplona.quartz.buzz.mpProjects.tags.ProjectMember +import com.vitorpamplona.quartz.buzz.mpProjects.tags.ProjectMemberTag +import com.vitorpamplona.quartz.buzz.mpProjects.tags.ProjectVisibility +import com.vitorpamplona.quartz.nip01Core.core.Address +import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class ProjectEventTest { + private val ownerA = "a".repeat(64) + private val ownerB = "b".repeat(64) + private val channel = "3580ca9b-47b4-4af9-b22a-1068778f26c6" + + private fun EventTemplate.toEvent() = ProjectEvent("0".repeat(64), "f".repeat(64), createdAt, tags, content, "sig") + + private fun tags(vararg tags: Array) = arrayOf(*tags) + + // ---- Cases from Buzz's docs/nips/NIP-MP.fixtures.json (the full file runs in jvmTest). ---- + + @Test + fun fixtureValidFull() { + val t = + tags( + arrayOf("d", "platform"), + arrayOf("name", "Platform"), + arrayOf("description", "Relay, desktop, and mobile for the platform team."), + arrayOf("a", "30617:$ownerA:buzz"), + arrayOf("a", "30617:$ownerB:buzz-infra"), + arrayOf("buzz-channel", channel), + arrayOf("buzz-visibility", "listed"), + ) + assertNull(ProjectValidator.validate(t)) + val ev = ProjectEvent("0".repeat(64), "f".repeat(64), 1, t, "", "sig") + assertEquals("platform", ev.slug()) + assertEquals("Platform", ev.displayName()) + assertEquals(listOf("30617:$ownerA:buzz", "30617:$ownerB:buzz-infra"), ev.members().map { it.coordinate }) + assertEquals(Address(30617, ownerB, "buzz-infra"), ev.memberAddresses()[1]) + assertEquals(channel, ev.channelId()) + assertTrue(ev.isListed()) + } + + @Test + fun fixtureValidMemberDTagContainsColon() { + val t = tags(arrayOf("d", "platform"), arrayOf("a", "30617:$ownerA:buzz:infra")) + assertNull(ProjectValidator.validate(t)) + val ev = ProjectEvent("0".repeat(64), "f".repeat(64), 1, t, "", "sig") + assertEquals( + "buzz:infra", + ev + .members() + .single() + .address.dTag, + ) + assertEquals("platform", ev.displayName()) + } + + @Test + fun fixtureValidMemberRelayHint() { + val t = tags(arrayOf("d", "platform"), arrayOf("a", "30617:$ownerA:buzz", "wss://relay.example.com")) + assertNull(ProjectValidator.validate(t)) + assertEquals("wss://relay.example.com", ProjectEvent("0".repeat(64), "f".repeat(64), 1, t, "", "sig").members().single().relayHint) + } + + @Test + fun fixtureRejections() { + val cases = + listOf( + ProjectRule.D_CARDINALITY to tags(arrayOf("name", "Platform")), + ProjectRule.D_CARDINALITY to tags(arrayOf("d", "one"), arrayOf("d", "two")), + ProjectRule.D_EMPTY to tags(arrayOf("d", "")), + ProjectRule.MEMBER_DUPLICATE to + tags( + arrayOf("d", "platform"), + arrayOf("a", "30617:$ownerA:buzz", "wss://relay-one.example.com"), + arrayOf("a", "30617:$ownerA:buzz", "wss://relay-two.example.com"), + ), + ProjectRule.MEMBER_TAG_ARITY to tags(arrayOf("d", "platform"), arrayOf("a", "30617:$ownerA:buzz", "wss://relay.example.com", "unexpected")), + ProjectRule.MEMBER_COORDINATE_MALFORMED to tags(arrayOf("d", "platform"), arrayOf("a", "30617:${ownerA.uppercase()}:buzz")), + ProjectRule.MEMBER_COORDINATE_MALFORMED to tags(arrayOf("d", "platform"), arrayOf("a", "30618:$ownerA:buzz")), + ProjectRule.MEMBER_COORDINATE_MALFORMED to tags(arrayOf("d", "platform"), arrayOf("a", "30617:$ownerA:")), + ProjectRule.MEMBER_COORDINATE_MALFORMED to tags(arrayOf("d", "platform"), arrayOf("a", "30617:$ownerA")), + ProjectRule.MEMBER_CAP to tags(arrayOf("d", "platform"), *Array(65) { arrayOf("a", "30617:$ownerA:repo-$it") }), + ProjectRule.MEMBER_CAP to tags(arrayOf("d", "platform"), *Array(65) { arrayOf("a", "30617:$ownerA:buzz") }), + ProjectRule.METADATA_CARDINALITY to tags(arrayOf("d", "platform"), arrayOf("name", "A"), arrayOf("name", "B")), + ProjectRule.METADATA_LENGTH to tags(arrayOf("d", "platform"), arrayOf("name", "n".repeat(257))), + ProjectRule.METADATA_LENGTH to tags(arrayOf("d", "platform"), arrayOf("description", "n".repeat(2049))), + ProjectRule.METADATA_LENGTH to tags(arrayOf("d", "platform"), arrayOf("buzz-visibility", "v".repeat(257))), + ) + for ((rule, t) in cases) { + assertEquals(rule, ProjectValidator.validate(t)?.rule, t.joinToString { it.toList().toString() }.take(200)) + } + // The cap is inclusive. + assertNull(ProjectValidator.validate(tags(arrayOf("d", "platform"), *Array(64) { arrayOf("a", "30617:$ownerA:repo-$it") }))) + } + + @Test + fun metadataIsNotInterpretedAtIngest() { + val t = tags(arrayOf("d", "platform"), arrayOf("buzz-channel", "not-a-uuid"), arrayOf("buzz-visibility", "secret"), arrayOf("x-future", "1")) + assertNull(ProjectValidator.validate(t)) + // An unknown visibility token never hides a project. + assertEquals(ProjectVisibility.LISTED, ProjectEvent("0".repeat(64), "f".repeat(64), 1, t, "ignored", "sig").visibility()) + } + + @Test + fun buildMirrorsTheSdkWriterPolicy() { + val members = + listOf( + ProjectMember(Address(30617, ownerA, "buzz")), + ProjectMember(Address(30617, ownerB, "buzz-infra"), "wss://relay.example.com"), + ) + val ev = + ProjectEvent + .build("platform", "Platform", "Relay, desktop, and mobile.", members, channel, ProjectVisibility.UNLISTED, createdAt = 5) + .toEvent() + + assertEquals(30621, ev.kind) + assertEquals("", ev.content) + assertEquals(listOf("d", "name", "description", "a", "a", "buzz-channel", "buzz-visibility"), ev.tags.map { it[0] }) + assertEquals(members, ev.members()) + assertEquals(ProjectVisibility.UNLISTED, ev.visibility()) + assertTrue(ev.isWellFormed()) + + assertFailsWith { ProjectEvent.build("") } + assertFailsWith { ProjectEvent.build("p", channelId = "general") } + assertFailsWith { ProjectEvent.build("p", name = "n".repeat(257)) } + assertFailsWith { ProjectEvent.build("p", members = List(2) { members[0] }) } + } + + @Test + fun strictCoordinateParsing() { + assertEquals(Address(30617, ownerA, "a:b:c"), ProjectMemberTag.parseCoordinate("30617:$ownerA:a:b:c")) + assertNull(ProjectMemberTag.parseCoordinate("30617:${"g".repeat(64)}:x")) + assertNull(ProjectMemberTag.parseCoordinate("30617:${"a".repeat(63)}:x")) + assertNull(ProjectMemberTag.parse(arrayOf("a"))) + assertEquals(arrayOf("a", "30617:$ownerA:x").toList(), ProjectMemberTag.assemble(ownerA, "x").toList()) + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/buzz/oaOwnerAttestation/OwnerAttestationTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/buzz/oaOwnerAttestation/OwnerAttestationTest.kt index 918581dbcf..60c318ec14 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/buzz/oaOwnerAttestation/OwnerAttestationTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/buzz/oaOwnerAttestation/OwnerAttestationTest.kt @@ -165,4 +165,57 @@ class OwnerAttestationTest { assertEquals(1713957000L, parsed?.createdAtBefore) assertEquals(1700000000L, parsed?.createdAtAfter) } + + @Test + fun timeBoundsAreStrictAndEveryClauseMustPass() { + assertTrue(AttestationConditions.timeBoundsAllow("", 0)) + assertTrue(AttestationConditions.timeBoundsAllow("created_at<200", 199)) + assertFalse(AttestationConditions.timeBoundsAllow("created_at<200", 200)) + assertTrue(AttestationConditions.timeBoundsAllow("created_at>100", 101)) + assertFalse(AttestationConditions.timeBoundsAllow("created_at>100", 100)) + // kind= is not evaluated at admission. + assertTrue(AttestationConditions.timeBoundsAllow("kind=1&created_at<200", 150)) + // Repeated clauses all apply, even though parse() would keep only the last one. + assertFalse(AttestationConditions.timeBoundsAllow("created_at<100&created_at<200", 150)) + } + + @Test + fun verifyForAuthAtCombinesSignatureAndTime() { + val attestation = OwnerAttestation.sign(agentPub, "created_at<2000", owner.privKey!!) + assertTrue(attestation.verifyForAuthAt(agentPub, 1999)) + assertFalse(attestation.verifyForAuthAt(agentPub, 2000)) + assertEquals(1999L, attestation.validUntil()) + assertNull(attestation.validFrom()) + } + + @Test + fun uppercaseHexIsRejected() { + val attestation = OwnerAttestation.sign(agentPub, "", owner.privKey!!) + // Buzz refuses uppercase owner keys and signatures before its permissive decoder. + assertFalse(OwnerAttestation(attestation.ownerPubKey.uppercase(), "", attestation.sig).verify(agentPub)) + assertFalse(OwnerAttestation(attestation.ownerPubKey, "", attestation.sig.uppercase()).verify(agentPub)) + assertNull(AuthTag.parse(arrayOf("auth", attestation.ownerPubKey.uppercase(), "", attestation.sig))) + assertNull(AuthTag.parse(arrayOf("auth", attestation.ownerPubKey, "", attestation.sig.uppercase()))) + // Exactly four elements. + assertNull(AuthTag.parse(arrayOf("auth", attestation.ownerPubKey, "", attestation.sig, "extra"))) + } + + @Test + fun verifiedOwnerOfAProfileNeedsExactlyOneApplicableAuthTag() { + val ownerPub = owner.pubKey.toHexKey() + val tag = OwnerAttestation.sign(agentPub, "", owner.privKey!!).toTag() + assertEquals(ownerPub, OwnerAttestation.verifiedOwnerOf(agentPub, 0, 1_000, arrayOf(arrayOf("name", "x"), tag))) + + // No first-valid-tag fallback: a second auth tag, even a malformed one, voids it. + assertNull(OwnerAttestation.verifiedOwnerOf(agentPub, 0, 1_000, arrayOf(tag, arrayOf("auth", "junk")))) + // It must authorize this author. + assertNull(OwnerAttestation.verifiedOwnerOf(KeyPair().pubKey.toHexKey(), 0, 1_000, arrayOf(tag))) + + // Every condition must apply to the profile event itself, kind= included. + val kindOne = OwnerAttestation.sign(agentPub, "kind=1", owner.privKey!!).toTag() + assertNull(OwnerAttestation.verifiedOwnerOf(agentPub, 0, 1_000, arrayOf(kindOne))) + val expired = OwnerAttestation.sign(agentPub, "created_at<500", owner.privKey!!).toTag() + assertNull(OwnerAttestation.verifiedOwnerOf(agentPub, 0, 1_000, arrayOf(expired))) + assertEquals(ownerPub, OwnerAttestation.verifiedOwnerOf(agentPub, 0, 400, arrayOf(expired))) + } } diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/buzz/relay/BuzzMembershipPolicyTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/buzz/relay/BuzzMembershipPolicyTest.kt index d9bc2d48dd..5b7ba8ff6c 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/buzz/relay/BuzzMembershipPolicyTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/buzz/relay/BuzzMembershipPolicyTest.kt @@ -36,6 +36,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.server.policies.PolicyResult import com.vitorpamplona.quartz.nip42RelayAuth.RelayAuthEvent import kotlinx.coroutines.test.runTest import kotlin.test.Test +import kotlin.test.assertFalse import kotlin.test.assertTrue class BuzzMembershipPolicyTest { @@ -142,4 +143,23 @@ class BuzzMembershipPolicyTest { assertTrue(reason(policy.accept(event(agentPub))).startsWith("restricted")) } + + @Test + fun attestationTimeBoundsAreCheckedAgainstTheAuthEvent() = + runTest { + // Valid only strictly between 1000 and 2000, like Buzz's verify_auth_tag_for_auth_event. + val attestation = OwnerAttestation.sign(agentPub, "created_at>1000&created_at<2000", owner.privKey!!) + + suspend fun admitted(authAt: Long): Boolean { + val policy = policyOn(setOf(agentPub)) + policy.onAuthenticated(RelayAuthEvent("00", agentPub, authAt, arrayOf(AuthTag.assemble(attestation)), "", "sig")) + return accepted(policy.accept(event(agentPub))) + } + + assertTrue(admitted(1500), "inside the window") + assertFalse(admitted(2500), "expired") + assertFalse(admitted(500), "not yet valid") + assertFalse(admitted(2000), "the upper bound is strict") + assertFalse(admitted(1000), "the lower bound is strict") + } } diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/buzz/rsReadState/ReadStateValidationTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/buzz/rsReadState/ReadStateValidationTest.kt new file mode 100644 index 0000000000..9a5221183c --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/buzz/rsReadState/ReadStateValidationTest.kt @@ -0,0 +1,234 @@ +/* + * 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.buzz.rsReadState + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** NIP-RS `d`-tag shape, "Content Validation", "Reserved Namespace" and the manual-unread override layer. */ +class ReadStateValidationTest { + private val slot = "0123456789abcdef0123456789abcdef" + + @Test + fun slotIdMustBeExactlyThirtyTwoLowercaseHex() { + assertTrue(ReadState.isValidSlotId(slot)) + assertTrue(ReadState.isValidSlotId(ReadState.newSlotId())) + assertFalse(ReadState.isValidSlotId(slot.uppercase())) + assertFalse(ReadState.isValidSlotId(slot.dropLast(1))) + assertFalse(ReadState.isValidSlotId(slot + "0")) + assertFalse(ReadState.isValidSlotId("phone-slot-1")) + + assertEquals(slot, ReadState.slotIdFrom("read-state:$slot")) + assertNull(ReadState.slotIdFrom("read-state:phone-slot-1")) + assertNull(ReadState.slotIdFrom("read-state:${slot.uppercase()}")) + assertNull(ReadState.slotIdFrom(slot)) + + assertTrue(ReadState.isReadState(arrayOf(arrayOf("d", "read-state:$slot"), arrayOf("t", "read-state")))) + assertFalse(ReadState.isReadState(arrayOf(arrayOf("d", "read-state:abc"), arrayOf("t", "read-state")))) + } + + @Test + fun structuralFaultsDiscardTheWholeBlob() { + assertFailsWith { ReadStateContent.decodeFromJson("not json") } + assertFailsWith { ReadStateContent.decodeFromJson("[]") } + assertFailsWith { ReadStateContent.decodeFromJson("""{"client_id":"a","contexts":{}}""") } + assertFailsWith { ReadStateContent.decodeFromJson("""{"v":"1","client_id":"a","contexts":{}}""") } + assertFailsWith { ReadStateContent.decodeFromJson("""{"v":1.5,"client_id":"a","contexts":{}}""") } + assertFailsWith { ReadStateContent.decodeFromJson("""{"v":1,"contexts":{}}""") } + assertFailsWith { ReadStateContent.decodeFromJson("""{"v":1,"client_id":"a"}""") } + assertFailsWith { ReadStateContent.decodeFromJson("""{"v":1,"client_id":"a","contexts":[]}""") } + } + + @Test + fun unknownVersionIsIgnoredNotParsed() { + val blob = ReadStateContent.decodeFromJson("""{"v":2,"client_id":"a","contexts":"whatever"}""") + assertEquals(2, blob.v) + assertFalse(blob.isSupported()) + assertTrue(blob.contexts.isEmpty()) + } + + @Test + fun badEntriesAreDroppedAndTheRestKept() { + val longKey = "k".repeat(257) + val okKey = "k".repeat(256) + val blob = + ReadStateContent.decodeFromJson( + """{"v":1,"client_id":"phone","contexts":{""" + + """"good":1700000000,"zero":0,"max":4294967295,""" + + """"negative":-1,"tooBig":4294967296,"fraction":1.5,"text":"yesterday","nul":null,"obj":{},""" + + """"$longKey":1,"$okKey":2}}""", + ) + assertTrue(blob.isSupported()) + assertEquals( + mapOf("good" to 1_700_000_000L, "zero" to 0L, "max" to 4_294_967_295L, okKey to 2L), + blob.contexts, + ) + } + + @Test + fun keyLimitCountsUtf8Bytes() { + // 128 two-byte characters = 256 bytes (kept); 129 = 258 bytes (dropped). + val kept = "é".repeat(128) + val dropped = "é".repeat(129) + val blob = ReadStateContent.decodeFromJson("""{"v":1,"client_id":"a","contexts":{"$kept":1,"$dropped":2}}""") + assertEquals(setOf(kept), blob.contexts.keys) + } + + @Test + fun moreThanTenThousandEntriesRejectsTheBlob() { + fun blob(n: Int) = (0 until n).joinToString(",", prefix = """{"v":1,"client_id":"a","contexts":{""", postfix = "}}") { "\"c$it\":1" } + + assertEquals(10_000, ReadStateContent.decodeFromJson(blob(10_000)).contexts.size) + assertFailsWith { ReadStateContent.decodeFromJson(blob(10_001)) } + } + + @Test + fun escapingIsABijectionOverTheReservedPrefixes() { + assertEquals("esc:ov_s:evil", ReadStateKeys.escape("ov_s:evil")) + assertEquals("esc:esc:foo", ReadStateKeys.escape("esc:foo")) + assertEquals("esc:ov_anything", ReadStateKeys.escape("ov_anything")) + assertEquals("channel-uuid", ReadStateKeys.escape("channel-uuid")) + listOf("ov_s:evil", "esc:foo", "esc:esc:bar", "plain", "msg:ab").forEach { + assertEquals(it, ReadStateKeys.unescape(ReadStateKeys.escape(it))) + } + // Exactly one `esc:` is stripped. + assertEquals("esc:foo", ReadStateKeys.unescape("esc:esc:foo")) + } + + @Test + fun frontiersUnescapeAndSkipOverrideEntries() { + val blob = + ReadStateContent.decodeFromJson( + """{"v":1,"client_id":"a","contexts":{"chan":10,"esc:ov_s:evil":20,"esc:esc:foo":30,""" + + """"ov_s:chan":2,"ov_c:chan":1,"ov_b:chan":10,"ov_future:x":5}}""", + ) + assertEquals(mapOf("chan" to 10L, "ov_s:evil" to 20L, "esc:foo" to 30L), blob.frontiers()) + assertEquals(mapOf("chan" to OverrideRegister(2, 1, 10)), blob.overrides()) + } + + @Test + fun overrideGroupsAreValidatedAsAWhole() { + val blob = + ReadStateContent.decodeFromJson( + """{"v":1,"client_id":"a","contexts":{""" + + // live: complete triple + """"live":5,"ov_s:live":1,"ov_c:live":0,"ov_b:live":5,""" + + // tombstone floor: lone ov_c + """"dead":5,"ov_c:dead":3,""" + + // partial group (no baseline): whole group rejected, frontier kept + """"partial":5,"ov_s:partial":1,"ov_c:partial":0,""" + + // one invalid sibling: whole group rejected, frontier kept + """"badsib":5,"ov_s:badsib":1,"ov_c:badsib":0,"ov_b:badsib":-1,""" + + // lone ov_s is not a tombstone + """"lones":5,"ov_s:lones":1}}""", + ) + assertEquals( + mapOf("live" to OverrideRegister(1, 0, 5), "dead" to OverrideRegister(0, 3, 0)), + blob.overrides(), + ) + assertEquals(setOf("live", "dead", "partial", "badsib", "lones"), blob.frontiers().keys) + assertFalse(blob.contexts.keys.any { it.endsWith(":partial") || it.endsWith(":badsib") || it.endsWith(":lones") }) + } + + @Test + fun anOverlongKeyRejectsItsWholeGroup() { + val ctx = "c".repeat(252) // "ov_s:" + 252 = 257 bytes + val blob = + ReadStateContent.decodeFromJson( + """{"v":1,"client_id":"a","contexts":{"ov_s:$ctx":1,"ov_c:$ctx":0,"ov_b:$ctx":5}}""", + ) + assertTrue(blob.overrides().isEmpty()) + } + + @Test + fun livenessIsClearWins() { + assertTrue(OverrideRegister(1, 0, 10).isActive(frontier = 10)) + assertFalse(OverrideRegister(1, 0, 10).isActive(frontier = 11)) // natural read past B + assertFalse(OverrideRegister(1, 1, 10).isActive(frontier = 5)) // S == C: clear wins + assertFalse(OverrideRegister(0, 0, 10).isActive(frontier = 5)) + } + + @Test + fun canonicalPublicationCompactsDeadRegistersToTheTombstoneFloor() { + assertEquals(OverrideRegister(2, 1, 10), OverrideRegister(2, 1, 10).canonicalize(frontier = 10)) + assertEquals(OverrideRegister(0, 3, 0), OverrideRegister(3, 2, 10).canonicalize(frontier = 11)) + assertEquals(OverrideRegister(0, 4, 0), OverrideRegister(2, 4, 10).canonicalize(frontier = 0)) + assertNull(OverrideRegister.VIRGIN.canonicalize(frontier = 0)) + + val built = + ReadStateContent.build( + clientId = "phone", + frontiers = mapOf("live" to 10L, "dead" to 20L, "ov_s:weird" to 30L), + overrides = mapOf("live" to OverrideRegister(1, 0, 10), "dead" to OverrideRegister(1, 0, 10), "virgin" to OverrideRegister.VIRGIN), + ) + assertEquals( + mapOf( + "live" to 10L, + "dead" to 20L, + "esc:ov_s:weird" to 30L, + "ov_s:live" to 1L, + "ov_c:live" to 0L, + "ov_b:live" to 10L, + "ov_c:dead" to 1L, + ), + built.contexts, + ) + // Round-trips through the wire validator unchanged. + assertEquals(built, ReadStateContent.decodeFromJson(built.encodeToJson())) + } + + @Test + fun actionsBumpPastBothCountersAndRefuseAtTheCeiling() { + val unread = OverrideRegister.VIRGIN.markUnread(frontier = 50)!! + assertEquals(OverrideRegister(1, 0, 50), unread) + assertTrue(unread.isActive(frontier = 50)) + + val read = unread.markRead(frontierAfter = 50)!! + assertEquals(OverrideRegister(1, 2, 50), read) + assertFalse(read.isActive(frontier = 50)) + + val max = ReadStateContent.MAX_VALUE + assertNull(OverrideRegister(max, 0, 5).markUnread(frontier = 5)) + // At the ceiling a mark-read only succeeds when the override is already inactive. + assertNull(OverrideRegister(max, 0, 5).markRead(frontierAfter = 5)) + assertEquals(OverrideRegister(max, 0, 5), OverrideRegister(max, 0, 5).markRead(frontierAfter = 6)) + } + + /** Two independently-dead registers must not merge into a live one — the reason tombstones exist. */ + @Test + fun mergeTakesComponentwiseMaxAcrossBlobs() { + val phone = ReadStateContent.build("phone", mapOf("chan" to 10L), mapOf("chan" to OverrideRegister(1, 0, 10))) + val laptop = ReadStateContent.build("laptop", mapOf("chan" to 8L), mapOf("chan" to OverrideRegister(1, 2, 8))) + val legacy = ReadStateContent(v = 2, clientId = "future", contexts = mapOf("chan" to 999L)) + + val merged = MergedReadState.merge(listOf(phone, laptop, legacy)) + assertEquals(10L, merged.frontier("chan")) + assertEquals(OverrideRegister(1, 2, 10), merged.overrides["chan"]) + assertFalse(merged.isOverrideActive("chan")) + assertFalse(merged.isUnread("chan", latestMessageTs = 10)) + assertTrue(merged.isUnread("chan", latestMessageTs = 11)) + assertTrue(merged.isUnread("never-read", latestMessageTs = 1)) + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/buzz/stream/BuzzChatMessageTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/buzz/stream/BuzzChatMessageTest.kt new file mode 100644 index 0000000000..530f14e9e7 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/buzz/stream/BuzzChatMessageTest.kt @@ -0,0 +1,72 @@ +/* + * 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.buzz.stream + +import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate +import com.vitorpamplona.quartz.nipC7Chats.ChatEvent +import kotlin.test.Test +import kotlin.test.assertEquals + +/** The kind-9 Buzz message must carry exactly the tags `build_message` emits, in its order. */ +class BuzzChatMessageTest { + private val channel = "6a39da2f-33c0-44f6-a050-c4da0138644a" + private val root = "a".repeat(64) + private val parent = "b".repeat(64) + private val alice = "c".repeat(64) + + private fun tags(t: EventTemplate) = t.tags.map { it.toList() } + + @Test + fun plainMessageIsKind9WithOnlyTheChannelTag() { + val t = BuzzChatMessage.build(channel, "hello") + assertEquals(ChatEvent.KIND, t.kind) + assertEquals("hello", t.content) + assertEquals(listOf(listOf("h", channel)), tags(t)) + } + + @Test + fun directReplyCollapsesToOneReplyMarker() { + val t = BuzzChatMessage.build(channel, "hi", threadRoot = parent, replyTo = parent, mentions = listOf(alice)) + assertEquals( + listOf( + listOf("h", channel), + listOf("e", parent, "", "reply"), + listOf("p", alice), + ), + tags(t), + ) + } + + @Test + fun nestedBroadcastReplyCarriesRootReplyMentionsThenBroadcast() { + val t = BuzzChatMessage.build(channel, "hi", threadRoot = root, replyTo = parent, mentions = listOf(alice, alice), broadcast = true) + assertEquals( + listOf( + listOf("h", channel), + listOf("e", root, "", "root"), + listOf("e", parent, "", "reply"), + listOf("p", alice), + listOf("broadcast", "1"), + ), + tags(t), + ) + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/buzz/stream/BuzzEditTagOverlayTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/buzz/stream/BuzzEditTagOverlayTest.kt new file mode 100644 index 0000000000..1637587b91 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/buzz/stream/BuzzEditTagOverlayTest.kt @@ -0,0 +1,111 @@ +/* + * 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.buzz.stream + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertSame +import kotlin.test.assertTrue + +/** Ports the cases in Buzz's `applyEditTagOverlay.test.mjs`. */ +class BuzzEditTagOverlayTest { + private fun imeta(url: String) = arrayOf("imeta", "url $url", "m image/png", "x x", "size 1") + + private fun emoji( + shortcode: String, + url: String, + ) = arrayOf("emoji", shortcode, url) + + private fun overlay( + original: Array>, + edit: Array>, + ) = BuzzEditTagOverlay.apply(original, edit).map { it.toList() } + + private fun List>.named(name: String) = filter { it[0] == name } + + @Test + fun noEditIsAPassThrough() { + val tags = arrayOf(arrayOf("h", "uuid"), imeta("https://b/a.png")) + assertSame(tags, BuzzEditTagOverlay.apply(tags, null)) + } + + @Test + fun editReplacesImetaAndKeepsOriginalNonImeta() { + val out = + overlay( + arrayOf(arrayOf("h", "uuid"), arrayOf("p", "mention1"), imeta("https://b/a.png"), imeta("https://b/b.png")), + arrayOf(arrayOf("h", "uuid"), arrayOf("e", "orig"), imeta("https://b/a.png"), imeta("https://b/c.png")), + ) + assertEquals(listOf(listOf("h", "uuid"), listOf("p", "mention1")), out.filter { it[0] != "imeta" }) + assertEquals(listOf("url https://b/a.png", "url https://b/c.png"), out.named("imeta").map { it[1] }) + } + + @Test + fun editWithoutImetaStripsAttachments() { + val out = overlay(arrayOf(arrayOf("h", "uuid"), imeta("https://b/a.png")), arrayOf(arrayOf("h", "uuid"), arrayOf("e", "x"))) + assertTrue(out.named("imeta").isEmpty()) + assertEquals(1, out.named("h").size) + } + + @Test + fun editsOwnChannelAndTargetTagsNeverLeak() { + val out = + overlay( + arrayOf(arrayOf("h", "uuid-original"), arrayOf("p", "mention1")), + arrayOf(arrayOf("h", "uuid-from-edit"), arrayOf("e", "target"), imeta("https://b/a.png")), + ) + assertEquals(listOf(listOf("h", "uuid-original")), out.named("h")) + assertTrue(out.named("e").isEmpty()) + assertEquals(1, out.named("imeta").size) + } + + @Test + fun addedMentionsJoinTheOriginalOnes() { + val out = overlay(arrayOf(arrayOf("h", "uuid"), arrayOf("p", "original")), arrayOf(arrayOf("h", "uuid"), arrayOf("e", "x"), arrayOf("p", "added"))) + assertEquals(listOf(listOf("p", "original"), listOf("p", "added")), out.named("p")) + } + + @Test + fun mentionSnapshotReplacesReferencesButKeepsAgentAddress() { + val original = arrayOf(arrayOf("h", "uuid"), arrayOf("mention", "agent", "agent-address"), arrayOf("mention", "old")) + val out = overlay(original, arrayOf(arrayOf("buzz:mention-snapshot"), arrayOf("mention", "agent", "agent-address"), arrayOf("mention", "new"))) + assertEquals(listOf(listOf("mention", "agent", "agent-address"), listOf("mention", "new")), out.named("mention")) + + val removed = overlay(arrayOf(arrayOf("h", "uuid"), arrayOf("mention", "old")), arrayOf(arrayOf("buzz:mention-snapshot"))) + assertTrue(removed.named("mention").isEmpty()) + + val legacy = overlay(arrayOf(arrayOf("h", "uuid"), arrayOf("mention", "old")), arrayOf(arrayOf("h", "uuid"), arrayOf("e", "x"))) + assertEquals(listOf(listOf("mention", "old")), legacy.named("mention")) + } + + @Test + fun emojiComesFromTheEditOnlyWhenItHasSome() { + val original = arrayOf(arrayOf("h", "uuid"), imeta("https://b/a.png"), emoji("catjam", "https://b/catjam.gif")) + + val replaced = overlay(original, arrayOf(arrayOf("e", "x"), emoji("catjam", "https://b/catjam.gif"), emoji("rickroll", "https://b/rr.gif"))) + assertEquals(listOf("catjam", "rickroll"), replaced.named("emoji").map { it[1] }) + + // A tag-less edit keeps the emoji but still clears the attachments. + val tagless = overlay(original, arrayOf(arrayOf("h", "uuid"), arrayOf("e", "x"))) + assertEquals(listOf(listOf("emoji", "catjam", "https://b/catjam.gif")), tagless.named("emoji")) + assertTrue(tagless.named("imeta").isEmpty()) + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/buzz/stream/CanvasEventTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/buzz/stream/CanvasEventTest.kt index 0a625443d4..8d13f8d576 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/buzz/stream/CanvasEventTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/buzz/stream/CanvasEventTest.kt @@ -20,8 +20,13 @@ */ package com.vitorpamplona.quartz.buzz.stream +import com.vitorpamplona.quartz.buzz.stream.tags.ExpectedRevisionTag import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue class CanvasEventTest { private val channelId = "3f2504e0-4f89-41d3-9a0c-0305e82c3301" @@ -57,4 +62,82 @@ class CanvasEventTest { assertEquals(channelId, event.channel()) assertEquals("# Doc", event.indexableContent()) } + + private val headId = "ab".repeat(32) + + @Test + fun buildOmitsExpectedRevisionByDefault() { + val template = CanvasEvent.build(channelId, "x", createdAt = 1L) + assertTrue(template.tags.none { it[0] == ExpectedRevisionTag.TAG_NAME }) + } + + @Test + fun buildEmitsExpectedRevisionHeadAndNone() { + val onHead = CanvasEvent.build(channelId, "x", createdAt = 1L, expectedRevision = headId) + assertEquals(listOf("expected-revision", headId), onHead.tags.single { it[0] == "expected-revision" }.toList()) + + val onNone = CanvasEvent.build(channelId, "x", createdAt = 1L, expectedRevision = ExpectedRevisionTag.NONE) + assertEquals(listOf("expected-revision", "none"), onNone.tags.single { it[0] == "expected-revision" }.toList()) + } + + @Test + fun buildRejectsMalformedExpectedRevision() { + assertFailsWith { CanvasEvent.build(channelId, "x", expectedRevision = "") } + assertFailsWith { CanvasEvent.build(channelId, "x", expectedRevision = "NONE") } + assertFailsWith { CanvasEvent.build(channelId, "x", expectedRevision = "ab".repeat(31)) } + assertFailsWith { CanvasEvent.build(channelId, "x", expectedRevision = "zz".repeat(32)) } + } + + @Test + fun expectedRevisionParsesOnlyTheExactTwoElementShape() { + fun canvas(vararg tags: Array) = CanvasEvent("00", "00", 0L, arrayOf(arrayOf("h", channelId), *tags), "", "00") + + assertEquals(headId, canvas(arrayOf("expected-revision", headId)).expectedRevision()) + assertEquals("none", canvas(arrayOf("expected-revision", "none")).expectedRevision()) + assertNull(canvas().expectedRevision()) + assertNull(canvas(arrayOf("expected-revision", headId, "extra")).expectedRevision()) + assertNull(canvas(arrayOf("expected-revision", "bogus")).expectedRevision()) + } + + @Test + fun writeCreatedAtStampsStrictlyAheadOfTheHead() { + val now = 1_700_000_000L + assertEquals(now, CanvasEvent.writeCreatedAt(null, now)) + assertEquals(now, CanvasEvent.writeCreatedAt(now - 100, now)) + assertEquals(now + 1, CanvasEvent.writeCreatedAt(now, now)) + assertEquals(now + 11, CanvasEvent.writeCreatedAt(now + 10, now)) + } + + @Test + fun writeCreatedAtRefusesAHeadPastTheSixtySecondCeiling() { + val now = 1_700_000_000L + // now + 60 is the last head a writer may ratchet past; the write lands at now + 61, + // inside the relay's 300 s canvas bound. + assertEquals(now + 61, CanvasEvent.writeCreatedAt(now + 60, now)) + assertTrue(now + 61 <= now + CanvasEvent.RELAY_MAX_FUTURE_SECS) + assertNull(CanvasEvent.writeCreatedAt(now + 61, now)) + assertNull(CanvasEvent.writeCreatedAt(Long.MAX_VALUE, now)) + } + + @Test + fun headOrderIsCreatedAtDescThenIdAsc() { + val small = "01".repeat(32) + val big = "ff".repeat(32) + assertTrue(CanvasEvent.isNewerHead(10, big, null, null)) + assertTrue(CanvasEvent.isNewerHead(11, big, 10, small)) + assertFalse(CanvasEvent.isNewerHead(9, small, 10, big)) + // Same second: the smallest id wins, regardless of arrival order. + assertTrue(CanvasEvent.isNewerHead(10, small, 10, big)) + assertFalse(CanvasEvent.isNewerHead(10, big, 10, small)) + // A revision never displaces itself. + assertFalse(CanvasEvent.isNewerHead(10, small, 10, small)) + } + + @Test + fun conflictPrefixMatchesTheRelayMarkers() { + assertTrue(CanvasEvent.isConflict("conflict: canvas changed since it was loaded")) + assertTrue(CanvasEvent.isConflict("conflict: canvas revision does not exist")) + assertFalse(CanvasEvent.isConflict("invalid: bad expected canvas revision")) + assertFalse(CanvasEvent.isConflict(null)) + } } diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/buzz/stream/SystemMessageEventTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/buzz/stream/SystemMessageEventTest.kt index 9f7284f6b7..c46d891865 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/buzz/stream/SystemMessageEventTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/buzz/stream/SystemMessageEventTest.kt @@ -23,6 +23,7 @@ package com.vitorpamplona.quartz.buzz.stream import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertNotNull +import kotlin.test.assertNull class SystemMessageEventTest { private val channelId = "3f2504e0-4f89-41d3-9a0c-0305e82c3301" @@ -142,4 +143,24 @@ class SystemMessageEventTest { assertEquals("pinned_changed", payload.type) assertEquals(actor, payload.actor) } + + /** `admin_outbox_worker.rs` emits `{type, target, action_id}` with no actor at all. */ + @Test + fun adminKickCarriesTargetAndActionButNoActor() { + val payload = SystemMessagePayload.decodeFromJson("""{"type":"admin_kick","target":"$target","action_id":"5f0c"}""") + assertEquals(SystemMessagePayload.ADMIN_KICK, payload.type) + assertEquals(target, payload.target) + assertEquals("5f0c", payload.actionId) + assertNull(payload.actor) + assertEquals(target, payload.subject()) + } + + /** The ephemeral-channel reaper (`main.rs`) emits a bare `{type}`. */ + @Test + fun channelAutoArchivedHasNoPeople() { + val payload = SystemMessagePayload.decodeFromJson("""{"type":"channel_auto_archived"}""") + assertEquals(SystemMessagePayload.CHANNEL_AUTO_ARCHIVED, payload.type) + assertNull(payload.actor) + assertNull(payload.subject()) + } } diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/buzz/teamCatalog/TeamCatalogEventTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/buzz/teamCatalog/TeamCatalogEventTest.kt new file mode 100644 index 0000000000..18232d97a7 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/buzz/teamCatalog/TeamCatalogEventTest.kt @@ -0,0 +1,178 @@ +/* + * 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.buzz.teamCatalog + +import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class TeamCatalogEventTest { + private val teamId = "builtin-team:welcome" + + private fun EventTemplate.toEvent() = TeamCatalogEvent("0".repeat(64), "f".repeat(64), createdAt, tags, content, "sig") + + private fun event( + content: String, + tags: Array> = arrayOf(arrayOf("d", teamId), arrayOf("shared", "true")), + ) = TeamCatalogEvent("0".repeat(64), "f".repeat(64), 1, tags, content, "sig") + + private val reviewer = + TeamCatalogMember( + memberKey = "k".repeat(64), + displayName = "Reviewer", + systemPrompt = "Review every PR.", + runtime = "goose", + namePool = listOf("Ada", "Grace"), + respondTo = "owner-only", + parallelism = 2, + sessionPolicy = AcpSessionPolicy.THREAD, + ) + + @Test + fun encodesInUpstreamFieldOrderAndOmitsAbsentFields() { + val content = + TeamCatalogContent( + v = 1, + name = "Welcome", + description = "Starter team", + members = listOf(reviewer, TeamCatalogMember(memberKey = "m2", displayName = "Scribe")), + ) + // serde_json output of the same `TeamCatalogContent` (declaration order, skip_serializing_if). + val expected = + """{"v":1,"name":"Welcome","description":"Starter team","members":[""" + + """{"member_key":"${"k".repeat(64)}","display_name":"Reviewer","system_prompt":"Review every PR.","runtime":"goose",""" + + """"name_pool":["Ada","Grace"],"respond_to":"owner-only","parallelism":2,"session_policy":"thread"},""" + + """{"member_key":"m2","display_name":"Scribe"}]}""" + assertEquals(expected, content.encodeToJson()) + assertEquals(content, TeamCatalogContent.decodeFromJson(expected)) + } + + @Test + fun emptyMembersAreStillWritten() { + assertEquals("""{"v":1,"name":"Solo","members":[]}""", TeamCatalogContent(v = 1, name = "Solo", members = emptyList()).encodeToJson()) + } + + @Test + fun buildRoundTrips() { + val content = TeamCatalogContent(v = 1, name = "Welcome", instructions = "Be kind.", members = listOf(reviewer)) + val ev = TeamCatalogEvent.build(content, teamId, shared = true, createdAt = 100).toEvent() + + assertEquals(30178, ev.kind) + assertEquals(teamId, ev.teamId()) + assertTrue(ev.isShared()) + assertTrue(ev.isEnvelopeValid()) + assertEquals(content, ev.catalog()) + + val unshared = TeamCatalogEvent.build(content, teamId, shared = false, createdAt = 100).toEvent() + assertFalse(unshared.isShared()) + assertEquals(listOf("d"), unshared.tags.map { it[0] }) + } + + @Test + fun republishingNeverSortsBehindThePriorHead() { + val content = TeamCatalogContent(v = 1, name = "Welcome", members = emptyList()) + val future = 4_000_000_000L + assertEquals(future + 1, TeamCatalogEvent.build(content, teamId, shared = true, priorHeadCreatedAt = future).createdAt) + } + + @Test + fun readerRejectsWhatUpstreamRejects() { + // Missing `v`, unsupported `v`, wrong-typed field, over the member cap, repeated key. + assertNull(event("""{"name":"No Version","members":[]}""").catalogOrNull()) + assertNull(event("""{"v":2,"name":"Future","members":[]}""").catalogOrNull()) + assertNull(event("""{"v":1,"name":"Bad","members":[{"member_key":"m1","display_name":"One","parallelism":"lots"}]}""").catalogOrNull()) + val tooMany = (0..TeamCatalogContent.MAX_MEMBERS).joinToString(",") { """{"member_key":"m$it","display_name":"M$it"}""" } + assertNull(event("""{"v":1,"name":"Too Many","members":[$tooMany]}""").catalogOrNull()) + assertNull(event("""{"v":1,"name":"Twins","members":[{"member_key":"k","display_name":"One"},{"member_key":"k","display_name":"Two"}]}""").catalogOrNull()) + assertNull(event("""{"v":1,"name":" ","members":[]}""").catalogOrNull()) + } + + @Test + fun membersViolatingTheV1ContractAreRejected() { + val fields = + listOf( + """"parallelism":999""", + """"parallelism":0""", + """"respond_to":"everyone"""", + """"runtime":""""", + """"model":"${"m".repeat(257)}"""", + """"name_pool":[""]""", + """"builtin_slug":"reviewer"""", + """"projection_hash":"${"a".repeat(64)}"""", + """"builtin_slug":"reviewer","projection_hash":"xyz"""", + """"avatar_url":"javascript:alert(1)"""", + ) + for (field in fields) { + assertNull(event("""{"v":1,"name":"T","members":[{"member_key":"k","display_name":"One",$field}]}""").catalogOrNull(), field) + } + val ok = event("""{"v":1,"name":"T","members":[{"member_key":"k","display_name":"One","avatar_url":"https://example.com/a.png","parallelism":32}]}""") + assertNotNull(ok.catalogOrNull()) + } + + @Test + fun sessionPolicyIsLenientLikeUpstream() { + fun policy(raw: String) = TeamCatalogContent.decodeFromJson("""{"v":1,"name":"T","members":[{"member_key":"k","display_name":"One","session_policy":$raw}]}""").members[0].sessionPolicy + + assertEquals(AcpSessionPolicy.THREAD, policy("\"thread\"")) + assertEquals(AcpSessionPolicy.CHANNEL, policy("\"channel\"")) + assertEquals(AcpSessionPolicy.CHANNEL, policy("\"weird\"")) + assertEquals(AcpSessionPolicy.CHANNEL, policy("5")) + assertEquals(AcpSessionPolicy.CHANNEL, policy("null")) + // The default (channel) is never written. + assertFalse(TeamCatalogMember("k", "One").let { TeamCatalogContent(1, "T", members = listOf(it)) }.encodeToJson().contains("session_policy")) + } + + @Test + fun unknownFieldsAreIgnored() { + val parsed = event("""{"v":1,"name":"T","future":{"x":1},"members":[{"member_key":"k","display_name":"One","later":true}]}""").catalogOrNull() + assertNotNull(parsed) + assertEquals("One", parsed.members[0].displayName) + } + + @Test + fun envelopeRules() { + val ok = arrayOf(arrayOf("d", teamId)) + assertNull(TeamCatalogEvent.envelopeError(ok)) + assertNull(TeamCatalogEvent.envelopeError(arrayOf(arrayOf("d", teamId), arrayOf("shared", "true")))) + assertNotNull(TeamCatalogEvent.envelopeError(emptyArray())) + assertNotNull(TeamCatalogEvent.envelopeError(arrayOf(arrayOf("d", "")))) + assertNotNull(TeamCatalogEvent.envelopeError(arrayOf(arrayOf("d"), arrayOf("d", teamId)))) + assertNotNull(TeamCatalogEvent.envelopeError(arrayOf(arrayOf("d", "a".repeat(65))))) + assertNull(TeamCatalogEvent.envelopeError(arrayOf(arrayOf("d", "a".repeat(64))))) + assertNotNull(TeamCatalogEvent.envelopeError(arrayOf(arrayOf("d", "team 1")))) + assertNotNull(TeamCatalogEvent.envelopeError(arrayOf(arrayOf("d", "team\n1")))) + assertNotNull(TeamCatalogEvent.envelopeError(arrayOf(arrayOf("d", teamId), arrayOf("shared", "false")))) + assertNotNull(TeamCatalogEvent.envelopeError(arrayOf(arrayOf("d", teamId), arrayOf("shared", "true", "extra")))) + assertNotNull(TeamCatalogEvent.envelopeError(arrayOf(arrayOf("d", teamId), arrayOf("shared", "true"), arrayOf("shared", "true")))) + } + + @Test + fun buildRefusesInvalidInput() { + val content = TeamCatalogContent(v = 1, name = "T", members = emptyList()) + assertFailsWith { TeamCatalogEvent.build(content, "", shared = true) } + assertFailsWith { TeamCatalogEvent.build(content.copy(v = 2), teamId, shared = true) } + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/buzz/threading/BuzzThreadMarkersTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/buzz/threading/BuzzThreadMarkersTest.kt new file mode 100644 index 0000000000..fc641382c5 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/buzz/threading/BuzzThreadMarkersTest.kt @@ -0,0 +1,95 @@ +/* + * 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.buzz.threading + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +/** Mirrors the tests in Buzz's `buzz-core/src/nip10.rs`, plus the reply-root derivation built on it. */ +class BuzzThreadMarkersTest { + private val a = "a".repeat(64) + private val b = "b".repeat(64) + + private fun markers(vararg tags: Array) = arrayOf(*tags).buzzThreadMarkers() + + @Test + fun noETagsYieldNoMarkers() { + assertEquals(BuzzThreadMarkers(null, null), markers()) + } + + @Test + fun rootAndReplyBothParsed() { + val m = markers(arrayOf("e", a, "", "root"), arrayOf("e", b, "", "reply")) + assertEquals(a, m.root) + assertEquals(b, m.reply) + assertEquals(a to b, m.resolve()) + } + + @Test + fun replyOnlyIsADirectReplyToTheRoot() { + val m = markers(arrayOf("e", a, "", "reply")) + assertNull(m.root) + assertEquals(a to a, m.resolve()) + } + + @Test + fun rootOnlyIsTopLevel() { + assertNull(markers(arrayOf("e", a, "", "root")).resolve()) + } + + @Test + fun bareAndThreeElementTagsAreIgnored() { + assertEquals(BuzzThreadMarkers(null, null), markers(arrayOf("e", a))) + // The relay needs the marker at index 3; ["e", id, "reply"] is not a thread link. + assertEquals(BuzzThreadMarkers(null, null), markers(arrayOf("e", a, "reply"))) + } + + @Test + fun malformedIdsAreIgnored() { + assertEquals(BuzzThreadMarkers(null, null), markers(arrayOf("e", "bad", "", "root"), arrayOf("e", "z".repeat(64), "", "reply"))) + // A valid root with a malformed reply is top-level. + val m = markers(arrayOf("e", a, "", "root"), arrayOf("e", "bad", "", "reply")) + assertEquals(a, m.root) + assertNull(m.reply) + assertNull(m.resolve()) + } + + @Test + fun lastValidOccurrenceWins() { + val m = markers(arrayOf("e", a, "", "reply"), arrayOf("e", b, "", "reply")) + assertEquals(b, m.reply) + } + + @Test + fun replyRootFollowsTheRelaysDerivation() { + val own = "c".repeat(64) + // Parent is top-level: it starts the thread. + assertEquals(own, arrayOf>().buzzThreadRootForReplyTo(own)) + // Parent is a direct reply to a: the thread root is a. + assertEquals(a, arrayOf(arrayOf("e", a, "", "reply")).buzzThreadRootForReplyTo(own)) + // Parent is a nested reply: its root marker is the thread root. + assertEquals(a, arrayOf(arrayOf("e", a, "", "root"), arrayOf("e", b, "", "reply")).buzzThreadRootForReplyTo(own)) + // Parent carries only a root marker (NIP-10 style): the relay treats it as top-level, so + // replying to it roots at the parent itself, not at its root marker. + assertEquals(own, arrayOf(arrayOf("e", a, "", "root")).buzzThreadRootForReplyTo(own)) + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/buzz/workflow/WorkflowDefEventTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/buzz/workflow/WorkflowDefEventTest.kt index ecf0c994a6..5b35bd6eb1 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/buzz/workflow/WorkflowDefEventTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/buzz/workflow/WorkflowDefEventTest.kt @@ -61,4 +61,17 @@ class WorkflowDefEventTest { assertNull(ev.name()) assertEquals(channel, ev.channel()) } + + @Test + fun expectedRevisionIsOmittedOnCreateAndCarriedOnUpdate() { + val create = WorkflowDefEvent.build(workflowId, channel, yaml) + assertNull(WorkflowDefEvent("00", "f".repeat(64), create.createdAt, create.tags, create.content, "sig").expectedRevision()) + + val head = "a".repeat(64) + val update = WorkflowDefEvent.build(workflowId, channel, yaml, expectedRevision = head) + val ev = WorkflowDefEvent("00", "f".repeat(64), update.createdAt, update.tags, update.content, "sig") + assertEquals(head, ev.expectedRevision()) + // Same wire shape as Buzz's build_workflow_update: ["expected-revision", ]. + assertEquals(listOf("expected-revision", head), update.tags.single { it[0] == "expected-revision" }.toList()) + } } diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/buzz/workspace/BuzzChannelNameTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/buzz/workspace/BuzzChannelNameTest.kt new file mode 100644 index 0000000000..566d070745 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/buzz/workspace/BuzzChannelNameTest.kt @@ -0,0 +1,52 @@ +/* + * 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.buzz.workspace + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +/** Mirrors `channel_names_trim_whitespace_and_drop_all_leading_hashes` in `buzz-core/src/channel.rs`. */ +class BuzzChannelNameTest { + @Test + fun canonicalNameMatchesUpstreamVectors() { + assertEquals("channel", canonicalBuzzChannelName("channel")) + assertEquals("channel", canonicalBuzzChannelName("#channel")) + assertEquals("channel", canonicalBuzzChannelName("###channel")) + assertEquals("channel", canonicalBuzzChannelName(" ###channel ")) + assertEquals("channel", canonicalBuzzChannelName("# channel")) + assertEquals("channel", canonicalBuzzChannelName("### channel ")) + assertEquals("", canonicalBuzzChannelName(" ### ")) + assertEquals("", canonicalBuzzChannelName("# #")) + assertEquals("", canonicalBuzzChannelName("### ###")) + assertEquals("channel#topic", canonicalBuzzChannelName("channel#topic")) + } + + @Test + fun aNameOfOnlyHashesAndWhitespaceIsRefused() { + assertTrue(isValidBuzzChannelName("#design")) + assertFalse(isValidBuzzChannelName("")) + assertFalse(isValidBuzzChannelName(" ")) + assertFalse(isValidBuzzChannelName("# #")) + assertFalse(isValidBuzzChannelName("\t##\n")) + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/buzz/workspace/BuzzCustomEmojiTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/buzz/workspace/BuzzCustomEmojiTest.kt new file mode 100644 index 0000000000..c6480df17f --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/buzz/workspace/BuzzCustomEmojiTest.kt @@ -0,0 +1,53 @@ +/* + * 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.buzz.workspace + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** `normalize_custom_emoji_shortcode` in `buzz-sdk/src/builders.rs`, which the relay runs on 30030/10030 `emoji` tags. */ +class BuzzCustomEmojiTest { + @Test + fun acceptsTheNip30AlphabetUpToSixtyFourBytes() { + assertTrue(BuzzCustomEmoji.isValidShortcode("party_parrot")) + assertTrue(BuzzCustomEmoji.isValidShortcode("Soap-Box_123")) + assertTrue(BuzzCustomEmoji.isValidShortcode("a".repeat(64))) + assertFalse(BuzzCustomEmoji.isValidShortcode("a".repeat(65))) + } + + @Test + fun rejectsCharactersOutsideTheAlphabet() { + assertFalse(BuzzCustomEmoji.isValidShortcode("")) + assertFalse(BuzzCustomEmoji.isValidShortcode("::")) + assertFalse(BuzzCustomEmoji.isValidShortcode("soap box")) + assertFalse(BuzzCustomEmoji.isValidShortcode("café")) + assertFalse(BuzzCustomEmoji.isValidShortcode("a.b")) + } + + @Test + fun normalizesLikeTheRelay() { + assertEquals("partyparrot", BuzzCustomEmoji.normalizeShortcode(" :PartyParrot: ")) + assertNull(BuzzCustomEmoji.normalizeShortcode(": spaced :")) + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/pool/PoolRequestsRefusalTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/pool/PoolRequestsRefusalTest.kt index 91f5c03c4e..d7247c89bc 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/pool/PoolRequestsRefusalTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/pool/PoolRequestsRefusalTest.kt @@ -99,6 +99,22 @@ class PoolRequestsRefusalTest { assertTrue(suppressed.filterIsInstance().isEmpty(), "a thrice-refused filter must not be replayed again") } + @Test + fun aQueryTimeoutIsNotARefusal() = + kotlinx.coroutines.test.runTest { + // Buzz ends a REQ that hits its query deadline with `error: query timed out` instead of + // EOSE. That is a slow moment, not "this relay won't serve this filter", so the pool must + // keep replaying it however many times it happens. + val pool = PoolRequests(maxRefusalsBeforeSuppress = 2) + pool.addOrUpdate("sub", mapOf(relay to plainFilter()), null) + + repeat(4) { attempt -> + val sent = reconnectAndSync(pool) + assertEquals(1, sent.filterIsInstance().size, "reconnect #$attempt should replay the REQ") + close(pool, "sub", "error: query timed out") + } + } + @Test fun aMeaningfulFilterChangeReEnablesTheReq() = kotlinx.coroutines.test.runTest { diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip38UserStatus/UserStatusEventTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip38UserStatus/UserStatusEventTest.kt new file mode 100644 index 0000000000..9bf428cb07 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip38UserStatus/UserStatusEventTest.kt @@ -0,0 +1,71 @@ +/* + * 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.nip38UserStatus + +import com.vitorpamplona.quartz.nip30CustomEmoji.taggedEmojis +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class UserStatusEventTest { + private fun status( + content: String, + vararg tags: Array, + ) = UserStatusEvent("00", "00", 0L, arrayOf(arrayOf("d", "general"), *tags), content, "00") + + /** Buzz's `build_user_status` writes a plain emoji as a 2-element `["emoji", ]`. */ + @Test + fun readsBuzzTwoElementStatusEmoji() { + val event = status("In a meeting", arrayOf("emoji", "📅")) + assertEquals("📅", event.statusEmoji()) + // It is not a NIP-30 custom emoji, so it must not leak into that list. + assertTrue(event.taggedEmojis().isEmpty()) + assertFalse(event.isCleared()) + } + + @Test + fun nip30CustomEmojiIsNotAStatusEmoji() { + val event = status("hi :soapbox:", arrayOf("emoji", "soapbox", "https://example.com/soapbox.png")) + assertNull(event.statusEmoji()) + assertEquals(listOf("soapbox"), event.taggedEmojis().map { it.code }) + } + + @Test + fun bothShapesCanCoexist() { + val event = + status( + "hi :soapbox:", + arrayOf("emoji", "soapbox", "https://example.com/soapbox.png"), + arrayOf("emoji", "🌴"), + ) + assertEquals("🌴", event.statusEmoji()) + assertEquals(listOf("soapbox"), event.taggedEmojis().map { it.code }) + } + + @Test + fun anEmojiOnlyStatusIsNotCleared() { + assertFalse(status("", arrayOf("emoji", "🌴")).isCleared()) + assertTrue(status("").isCleared()) + assertTrue(status(" ", arrayOf("emoji", " ")).isCleared()) + } +} diff --git a/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/buzz/identityNames/IdentityNamePolicyTest.kt b/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/buzz/identityNames/IdentityNamePolicyTest.kt new file mode 100644 index 0000000000..b82684bef1 --- /dev/null +++ b/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/buzz/identityNames/IdentityNamePolicyTest.kt @@ -0,0 +1,95 @@ +/* + * 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.buzz.identityNames + +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.boolean +import kotlinx.serialization.json.int +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertTrue + +/** + * Runs Buzz's portable "contextual identity names" v1 fixtures (a vendored, unmodified copy of + * `mobile/test/shared/identity_names/identity-names.fixtures.json`, Apache-2.0) against the port. + */ +class IdentityNamePolicyTest { + private val fixtures: JsonObject = + Json + .parseToJsonElement( + IdentityNamePolicyTest::class.java + .getResourceAsStream("/buzz/identity-names.fixtures.json")!! + .bufferedReader() + .readText(), + ).jsonObject + + private fun JsonObject.string(key: String): String? = this[key]?.takeIf { it !is JsonNull }?.jsonPrimitive?.content + + @Test + fun conformsToEveryPortableFixture() { + assertEquals(IdentityNamePolicy.VERSION, fixtures["version"]!!.jsonPrimitive.int) + val cases = fixtures["cases"]!!.jsonArray + assertTrue(cases.isNotEmpty()) + + cases.forEach { element -> + val case = element.jsonObject + val identities = + case["identities"]!!.jsonArray.map { + val raw = it.jsonObject + NamingIdentity( + pubkey = raw.string("pubkey")!!, + name = raw.string("name")!!, + isAgent = raw["isAgent"]?.jsonPrimitive?.boolean ?: false, + ownerPubkey = raw.string("ownerPubkey"), + ) + } + val candidates = case["candidates"]?.takeIf { it !is JsonNull }?.jsonArray?.map { it.jsonPrimitive.content } + val actual = IdentityNamePolicy.resolve(identities, viewer = case.string("viewer"), candidates = candidates) + + val expected = + case["expected"]!!.jsonObject.mapValues { (_, value) -> + val obj = value.jsonObject + ResolvedIdentityName(obj.string("name")!!, obj.string("qualifier")) + } + assertEquals(expected, actual, "fixture ${case.string("name")}") + } + } + + @Test + fun trimsOnlyTheContractWhitespace() { + val key = "1".repeat(64) + // U+0085 is not ECMAScript whitespace; U+3000 is. + val result = IdentityNamePolicy.resolve(listOf(NamingIdentity(key, "\u0085Honey "))) + assertEquals(ResolvedIdentityName("\u0085Honey"), result[key]) + } + + @Test + fun rejectsAnInvalidKey() { + assertFailsWith { IdentityNamePolicy.resolve(listOf(NamingIdentity("nope", "Honey"))) } + assertFailsWith { IdentityNamePolicy.resolve(emptyList(), viewer = "nope") } + } +} diff --git a/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/buzz/mpProjects/ProjectFixturesTest.kt b/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/buzz/mpProjects/ProjectFixturesTest.kt new file mode 100644 index 0000000000..6f070bb870 --- /dev/null +++ b/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/buzz/mpProjects/ProjectFixturesTest.kt @@ -0,0 +1,91 @@ +/* + * 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.buzz.mpProjects + +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.int +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * Runs every case of Buzz's NIP-MP ingest oracle (`docs/nips/NIP-MP.fixtures.json`, copied + * verbatim to `resources/buzz/NIP-MP.fixtures.json`) through [ProjectValidator]: each + * `accept` must pass, and each `reject` must fail on one of the case's `reject_rules` — so the + * port cannot pass a reject case by refusing it for an unrelated reason. Upstream's relay and + * SDK validators run the same file. + */ +class ProjectFixturesTest { + private val fixtures = + Json + .parseToJsonElement( + this::class.java + .getResourceAsStream("/buzz/NIP-MP.fixtures.json")!! + .readBytes() + .decodeToString(), + ).jsonObject + + private fun JsonArray.toTags() = map { tag -> tag.jsonArray.map { it.jsonPrimitive.content }.toTypedArray() }.toTypedArray() + + @Test + fun everyFixtureCaseMatchesItsExpectation() { + assertEquals(ProjectEvent.KIND, fixtures["kind"]!!.jsonPrimitive.int) + assertEquals(ProjectValidator.MEMBER_CAP, fixtures["member_cap"]!!.jsonPrimitive.int) + + val cases = fixtures["cases"]!!.jsonArray + assertTrue(cases.size >= 31, "fixture file shrank: ${cases.size} cases") + + var accepted = 0 + var rejected = 0 + for (case in cases) { + val obj = case.jsonObject + val name = obj["name"]!!.jsonPrimitive.content + val template = obj["template"]!!.jsonObject + assertEquals(ProjectEvent.KIND, template["kind"]!!.jsonPrimitive.int, name) + val tags = template["tags"]!!.jsonArray.toTags() + val result = ProjectValidator.validate(tags) + + when (obj["expect"]!!.jsonPrimitive.content) { + "accept" -> { + assertNull(result, "$name should be accepted, got $result") + val event = ProjectEvent("0".repeat(64), "f".repeat(64), 1, tags, template["content"]!!.jsonPrimitive.content, "sig") + assertTrue(event.isWellFormed(), name) + accepted++ + } + "reject" -> { + assertNotNull(result, "$name should be rejected") + val allowed = obj["reject_rules"]!!.jsonArray.map { it.jsonPrimitive.content } + assertTrue(result.rule.id in allowed, "$name rejected by ${result.rule.id}, expected one of $allowed") + rejected++ + } + else -> error("unknown expectation in $name") + } + } + assertEquals(11, accepted) + assertEquals(20, rejected) + } +} diff --git a/quartz/src/jvmTest/resources/buzz/NIP-MP.fixtures.json b/quartz/src/jvmTest/resources/buzz/NIP-MP.fixtures.json new file mode 100644 index 0000000000..cfc570ec65 --- /dev/null +++ b/quartz/src/jvmTest/resources/buzz/NIP-MP.fixtures.json @@ -0,0 +1,1462 @@ +{ + "$comment": "NIP-MP conformance fixtures \u2014 the shared ingest contract for the relay validator, the Rust event builder, and the TypeScript event builder. Each case carries an UNSIGNED template: consumers sign it with their own test key, because signing fixes the event id and signature and a stored literal would not survive re-serialization. `expect` is the ingest outcome. `reject_rules` lists the validation rules that may fire; an implementation must reject for one of them, so it cannot pass by rejecting for an unrelated reason. This file covers single-event accept/reject only; the client-side fold has its own oracle in NIP-MP.fold-fixtures.json. Spec: docs/nips/NIP-MP.md.", + "version": 1, + "kind": 30621, + "member_cap": 64, + "owners": { + "a": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "b": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + }, + "cases": [ + { + "name": "valid_minimal", + "expect": "accept", + "note": "Only the identity tag. A project needs nothing but a `d` tag to be a valid addressable container.", + "template": { + "kind": 30621, + "content": "", + "tags": [ + [ + "d", + "platform" + ] + ] + } + }, + { + "name": "valid_full", + "expect": "accept", + "note": "Every specified tag present, with two members owned by two different pubkeys \u2014 the cross-owner grouping that motivates the kind.", + "template": { + "kind": 30621, + "content": "", + "tags": [ + [ + "d", + "platform" + ], + [ + "name", + "Platform" + ], + [ + "description", + "Relay, desktop, and mobile for the platform team." + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:buzz" + ], + [ + "a", + "30617:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:buzz-infra" + ], + [ + "buzz-channel", + "3580ca9b-47b4-4af9-b22a-1068778f26c6" + ], + [ + "buzz-visibility", + "listed" + ] + ] + } + }, + { + "name": "valid_zero_members", + "expect": "accept", + "note": "Zero-member project. Legal at the protocol layer: it is the natural state after removing a final member and carries only bounded metadata.", + "template": { + "kind": 30621, + "content": "", + "tags": [ + [ + "d", + "platform" + ], + [ + "name", + "Platform" + ] + ] + } + }, + { + "name": "valid_unlisted", + "expect": "accept", + "note": "Explicitly unlisted container. Accepted at ingest; the fold excludes it from listing eligibility, so its member keeps its implicit card.", + "template": { + "kind": 30621, + "content": "", + "tags": [ + [ + "d", + "skunkworks" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:buzz" + ], + [ + "buzz-visibility", + "unlisted" + ] + ] + } + }, + { + "name": "valid_member_cap_boundary", + "expect": "accept", + "note": "Exactly 64 distinct member `a` tags \u2014 the cap is inclusive. Paired with `invalid_member_cap_exceeded` to pin the boundary from both sides.", + "template": { + "kind": 30621, + "content": "", + "tags": [ + [ + "d", + "wide" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-00" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-01" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-02" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-03" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-04" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-05" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-06" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-07" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-08" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-09" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-10" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-11" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-12" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-13" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-14" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-15" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-16" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-17" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-18" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-19" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-20" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-21" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-22" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-23" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-24" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-25" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-26" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-27" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-28" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-29" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-30" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-31" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-32" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-33" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-34" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-35" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-36" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-37" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-38" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-39" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-40" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-41" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-42" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-43" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-44" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-45" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-46" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-47" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-48" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-49" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-50" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-51" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-52" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-53" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-54" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-55" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-56" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-57" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-58" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-59" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-60" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-61" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-62" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-63" + ] + ] + } + }, + { + "name": "valid_same_dtag_two_owners", + "expect": "accept", + "note": "Two members share a repo `d` segment under different owners (the NIP-34 fork case). Identity is the whole coordinate, so these are not duplicates.", + "template": { + "kind": 30621, + "content": "", + "tags": [ + [ + "d", + "forks" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:buzz" + ], + [ + "a", + "30617:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:buzz" + ] + ] + } + }, + { + "name": "valid_member_dtag_contains_colon", + "expect": "accept", + "note": "Repo `d` segment contains a colon. The coordinate splits into at most three parts, so the third part is the repo `d` value verbatim \u2014 `buzz:infra` here. Splitting on every colon instead would make any repo with a colon in its `d` tag unaddressable by a project.", + "template": { + "kind": 30621, + "content": "", + "tags": [ + [ + "d", + "platform" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:buzz:infra" + ] + ] + } + }, + { + "name": "valid_uninterpreted_metadata_values", + "expect": "accept", + "note": "`buzz-channel` is not a UUID and `buzz-visibility` is an unrecognized token. Ingest bounds metadata cardinality and length but does not interpret these values \u2014 matching how kind:30617 carries the same two tags. Client-side fallbacks for both are normative in the spec's Metadata interpretation section: an unresolvable channel renders the project without one, and an unrecognized visibility is treated as `listed`.", + "template": { + "kind": 30621, + "content": "", + "tags": [ + [ + "d", + "platform" + ], + [ + "buzz-channel", + "not-a-uuid" + ], + [ + "buzz-visibility", + "chartreuse" + ] + ] + } + }, + { + "name": "valid_unknown_tag_ignored", + "expect": "accept", + "note": "An unrecognized tag is neither rejected nor interpreted \u2014 unknown tags are ignored, not fatal.", + "template": { + "kind": 30621, + "content": "", + "tags": [ + [ + "d", + "platform" + ], + [ + "future-metadata", + "preserve-me" + ] + ] + } + }, + { + "name": "valid_member_relay_hint", + "expect": "accept", + "note": "Member `a` tag carries NIP-01's optional third element, a relay hint. Ingest validates the tag's arity and the coordinate in element 1, so the hint's content is neither parsed nor a rejection cause; a client MAY use it to resolve an otherwise unavailable member.", + "template": { + "kind": 30621, + "content": "", + "tags": [ + [ + "d", + "platform" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:buzz", + "wss://relay.example.com" + ] + ] + } + }, + { + "name": "valid_non_empty_content", + "expect": "accept", + "note": "`content` holds a value. Writers should emit the empty string, but readers and relays must ignore whatever is there \u2014 a non-empty `content` is not a rejection cause and carries no semantics.", + "template": { + "kind": 30621, + "content": "ignored by every consumer", + "tags": [ + [ + "d", + "platform" + ] + ] + } + }, + { + "name": "invalid_d_missing", + "expect": "reject", + "note": "No `d` tag. Without one the event collapses into the `(pubkey, 30621, \"\")` slot and silently last-write-wins over an unrelated project.", + "reject_rules": [ + "d-cardinality" + ], + "template": { + "kind": 30621, + "content": "", + "tags": [ + [ + "name", + "Platform" + ] + ] + } + }, + { + "name": "invalid_d_multiple", + "expect": "reject", + "note": "Two `d` tags. Which one addresses the event is reader-dependent; reject rather than pick.", + "reject_rules": [ + "d-cardinality" + ], + "template": { + "kind": 30621, + "content": "", + "tags": [ + [ + "d", + "platform" + ], + [ + "d", + "infra" + ] + ] + } + }, + { + "name": "invalid_d_empty", + "expect": "reject", + "note": "Present but empty `d`. Same slot-collapse hazard as a missing `d`.", + "reject_rules": [ + "d-empty" + ], + "template": { + "kind": 30621, + "content": "", + "tags": [ + [ + "d", + "" + ] + ] + } + }, + { + "name": "invalid_member_duplicate", + "expect": "reject", + "note": "The same canonical coordinate twice. A signed event cannot be normalized in place, so the relay refuses it rather than store a head every consumer must defensively dedupe.", + "reject_rules": [ + "member-duplicate" + ], + "template": { + "kind": 30621, + "content": "", + "tags": [ + [ + "d", + "platform" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:buzz" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:buzz" + ] + ] + } + }, + { + "name": "invalid_member_duplicate_differing_relay_hints", + "expect": "reject", + "note": "The same coordinate twice under different relay hints. Duplicate detection compares the coordinate alone, so differing hints do not make these distinct members; a validator that compares whole tag arrays would wrongly accept this.", + "reject_rules": [ + "member-duplicate" + ], + "template": { + "kind": 30621, + "content": "", + "tags": [ + [ + "d", + "platform" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:buzz", + "wss://relay-one.example.com" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:buzz", + "wss://relay-two.example.com" + ] + ] + } + }, + { + "name": "invalid_member_cap_exceeded", + "expect": "reject", + "note": "65 distinct member `a` tags \u2014 one past the inclusive cap of 64.", + "reject_rules": [ + "member-cap" + ], + "template": { + "kind": 30621, + "content": "", + "tags": [ + [ + "d", + "wide" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-00" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-01" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-02" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-03" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-04" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-05" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-06" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-07" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-08" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-09" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-10" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-11" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-12" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-13" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-14" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-15" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-16" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-17" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-18" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-19" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-20" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-21" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-22" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-23" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-24" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-25" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-26" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-27" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-28" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-29" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-30" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-31" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-32" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-33" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-34" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-35" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-36" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-37" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-38" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-39" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-40" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-41" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-42" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-43" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-44" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-45" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-46" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-47" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-48" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-49" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-50" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-51" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-52" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-53" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-54" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-55" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-56" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-57" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-58" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-59" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-60" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-61" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-62" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-63" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-64" + ] + ] + } + }, + { + "name": "invalid_member_cap_exceeded_by_duplicates", + "expect": "reject", + "note": "65 member `a` tags naming 33 distinct coordinates. The spec evaluates the cap before the duplicate set is built, so `member-cap` is the required rule even though the event also carries duplicates; a validator that reports `member-duplicate` here has built a set whose size is bounded only by the frame limit.", + "reject_rules": [ + "member-cap" + ], + "template": { + "kind": 30621, + "content": "", + "tags": [ + [ + "d", + "wide" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-00" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-00" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-01" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-01" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-02" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-02" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-03" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-03" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-04" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-04" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-05" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-05" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-06" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-06" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-07" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-07" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-08" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-08" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-09" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-09" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-10" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-10" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-11" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-11" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-12" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-12" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-13" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-13" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-14" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-14" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-15" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-15" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-16" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-16" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-17" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-17" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-18" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-18" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-19" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-19" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-20" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-20" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-21" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-21" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-22" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-22" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-23" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-23" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-24" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-24" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-25" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-25" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-26" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-26" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-27" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-27" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-28" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-28" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-29" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-29" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-30" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-30" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-31" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-31" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-32" + ] + ] + } + }, + { + "name": "invalid_member_tag_arity_four_elements", + "expect": "reject", + "note": "Member `a` tag carries a fourth element past the relay hint. Its coordinate is valid, so this rejects on tag shape rather than on the coordinate: NIP-01's `a` grammar is two or three elements, and a validator that reads element 1 and ignores the rest would accept unbounded unvalidated data in a position no consumer reads.", + "reject_rules": [ + "member-tag-arity" + ], + "template": { + "kind": 30621, + "content": "", + "tags": [ + [ + "d", + "platform" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:buzz", + "wss://relay.example.com", + "unexpected" + ] + ] + } + }, + { + "name": "invalid_member_kind_prefix", + "expect": "reject", + "note": "Coordinate names kind 30618 (repo state) rather than 30617 (repo announcement). A project groups repositories, not their ref state.", + "reject_rules": [ + "member-coordinate-malformed" + ], + "template": { + "kind": 30621, + "content": "", + "tags": [ + [ + "d", + "platform" + ], + [ + "a", + "30618:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:buzz" + ] + ] + } + }, + { + "name": "invalid_member_owner_not_hex", + "expect": "reject", + "note": "Owner segment is 64 characters but not hex.", + "reject_rules": [ + "member-coordinate-malformed" + ], + "template": { + "kind": 30621, + "content": "", + "tags": [ + [ + "d", + "platform" + ], + [ + "a", + "30617:zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz:buzz" + ] + ] + } + }, + { + "name": "invalid_member_owner_uppercase", + "expect": "reject", + "note": "Owner segment is uppercase hex. `#a` tag matching is byte-exact, so an uppercase head is invisible to the lowercase-coordinate queries every reader issues.", + "reject_rules": [ + "member-coordinate-malformed" + ], + "template": { + "kind": 30621, + "content": "", + "tags": [ + [ + "d", + "platform" + ], + [ + "a", + "30617:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA:buzz" + ] + ] + } + }, + { + "name": "invalid_member_owner_wrong_length", + "expect": "reject", + "note": "Owner segment is 63 hex characters.", + "reject_rules": [ + "member-coordinate-malformed" + ], + "template": { + "kind": 30621, + "content": "", + "tags": [ + [ + "d", + "platform" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:buzz" + ] + ] + } + }, + { + "name": "invalid_member_dtag_empty", + "expect": "reject", + "note": "Coordinate has an empty repo `d` segment \u2014 it addresses no announceable repository.", + "reject_rules": [ + "member-coordinate-malformed" + ], + "template": { + "kind": 30621, + "content": "", + "tags": [ + [ + "d", + "platform" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:" + ] + ] + } + }, + { + "name": "invalid_member_missing_segment", + "expect": "reject", + "note": "Coordinate has two segments instead of three.", + "reject_rules": [ + "member-coordinate-malformed" + ], + "template": { + "kind": 30621, + "content": "", + "tags": [ + [ + "d", + "platform" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + ] + ] + } + }, + { + "name": "invalid_metadata_duplicate_name", + "expect": "reject", + "note": "Two `name` tags. Reader-dependent display name; reject rather than pick.", + "reject_rules": [ + "metadata-cardinality" + ], + "template": { + "kind": 30621, + "content": "", + "tags": [ + [ + "d", + "platform" + ], + [ + "name", + "Platform" + ], + [ + "name", + "Infra" + ] + ] + } + }, + { + "name": "invalid_metadata_duplicate_channel", + "expect": "reject", + "note": "Two `buzz-channel` tags. Which channel the project links to would be reader-dependent.", + "reject_rules": [ + "metadata-cardinality" + ], + "template": { + "kind": 30621, + "content": "", + "tags": [ + [ + "d", + "platform" + ], + [ + "buzz-channel", + "3580ca9b-47b4-4af9-b22a-1068778f26c6" + ], + [ + "buzz-channel", + "00000000-0000-0000-0000-000000000000" + ] + ] + } + }, + { + "name": "invalid_metadata_name_too_long", + "expect": "reject", + "note": "`name` value exceeds 256 bytes.", + "reject_rules": [ + "metadata-length" + ], + "template": { + "kind": 30621, + "content": "", + "tags": [ + [ + "d", + "platform" + ], + [ + "name", + "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" + ] + ] + } + }, + { + "name": "invalid_metadata_description_too_long", + "expect": "reject", + "note": "`description` value exceeds 2048 bytes.", + "reject_rules": [ + "metadata-length" + ], + "template": { + "kind": 30621, + "content": "", + "tags": [ + [ + "d", + "platform" + ], + [ + "description", + "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" + ] + ] + } + }, + { + "name": "invalid_metadata_channel_too_long", + "expect": "reject", + "note": "`buzz-channel` value exceeds 256 bytes. Paired with `invalid_metadata_visibility_too_long` so each bound is exercised by an event whose other metadata is valid \u2014 neither check can hide behind the other's rejection.", + "reject_rules": [ + "metadata-length" + ], + "template": { + "kind": 30621, + "content": "", + "tags": [ + [ + "d", + "platform" + ], + [ + "buzz-channel", + "ccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc" + ] + ] + } + }, + { + "name": "invalid_metadata_visibility_too_long", + "expect": "reject", + "note": "`buzz-visibility` value exceeds 256 bytes. Ingest does not interpret the token, but it must still bound its length.", + "reject_rules": [ + "metadata-length" + ], + "template": { + "kind": 30621, + "content": "", + "tags": [ + [ + "d", + "platform" + ], + [ + "buzz-visibility", + "vvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvv" + ] + ] + } + } + ] +} diff --git a/quartz/src/jvmTest/resources/buzz/identity-names.fixtures.json b/quartz/src/jvmTest/resources/buzz/identity-names.fixtures.json new file mode 100644 index 0000000000..7bf01af9e0 --- /dev/null +++ b/quartz/src/jvmTest/resources/buzz/identity-names.fixtures.json @@ -0,0 +1,1787 @@ +{ + "$comment": "Contextual identity names v1: semantic inputs, no signed events. Spec: README.md. Preserve identities order; compare expected as a key-indexed map. null qualifier means no key suffix. Expected outputs are literal, not generated by the policy under test.", + "version": 1, + "cases": [ + { + "name": "unique_foreign_agent", + "identities": [ + { + "pubkey": "1111111111111111111111111111111111111111111111111111111111111111", + "name": "Logan" + }, + { + "pubkey": "2222222222222222222222222222222222222222222222222222222222222222", + "name": "Wes" + }, + { + "pubkey": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "name": "Honey", + "isAgent": true, + "ownerPubkey": "2222222222222222222222222222222222222222222222222222222222222222" + } + ], + "viewer": "1111111111111111111111111111111111111111111111111111111111111111", + "candidates": [ + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + ], + "expected": { + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa": { + "name": "Honey", + "qualifier": null + } + } + }, + { + "name": "human_before_mine_before_others", + "identities": [ + { + "pubkey": "1111111111111111111111111111111111111111111111111111111111111111", + "name": "Logan" + }, + { + "pubkey": "2222222222222222222222222222222222222222222222222222222222222222", + "name": "Wes" + }, + { + "pubkey": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "name": "Honey" + }, + { + "pubkey": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "name": "Honey", + "isAgent": true, + "ownerPubkey": "1111111111111111111111111111111111111111111111111111111111111111" + }, + { + "pubkey": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "name": "Honey", + "isAgent": true, + "ownerPubkey": "2222222222222222222222222222222222222222222222222222222222222222" + } + ], + "viewer": "1111111111111111111111111111111111111111111111111111111111111111", + "candidates": [ + "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + ], + "expected": { + "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc": { + "name": "Honey", + "qualifier": null + }, + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa": { + "name": "Honey (agent)", + "qualifier": null + }, + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb": { + "name": "Wes’s Honey", + "qualifier": null + } + } + }, + { + "name": "mine_before_other_agent", + "identities": [ + { + "pubkey": "1111111111111111111111111111111111111111111111111111111111111111", + "name": "Logan" + }, + { + "pubkey": "2222222222222222222222222222222222222222222222222222222222222222", + "name": "Wes" + }, + { + "pubkey": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "name": "Honey", + "isAgent": true, + "ownerPubkey": "1111111111111111111111111111111111111111111111111111111111111111" + }, + { + "pubkey": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "name": "Honey", + "isAgent": true, + "ownerPubkey": "2222222222222222222222222222222222222222222222222222222222222222" + } + ], + "viewer": "1111111111111111111111111111111111111111111111111111111111111111", + "candidates": [ + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + ], + "expected": { + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa": { + "name": "Honey", + "qualifier": null + }, + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb": { + "name": "Wes’s Honey", + "qualifier": null + } + } + }, + { + "name": "viewer_changes_labels", + "identities": [ + { + "pubkey": "1111111111111111111111111111111111111111111111111111111111111111", + "name": "Logan" + }, + { + "pubkey": "2222222222222222222222222222222222222222222222222222222222222222", + "name": "Wes" + }, + { + "pubkey": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "name": "Honey", + "isAgent": true, + "ownerPubkey": "1111111111111111111111111111111111111111111111111111111111111111" + }, + { + "pubkey": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "name": "Honey", + "isAgent": true, + "ownerPubkey": "2222222222222222222222222222222222222222222222222222222222222222" + } + ], + "viewer": "2222222222222222222222222222222222222222222222222222222222222222", + "candidates": [ + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + ], + "expected": { + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa": { + "name": "Logan’s Honey", + "qualifier": null + }, + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb": { + "name": "Honey", + "qualifier": null + } + } + }, + { + "name": "my_same_owner_duplicates", + "identities": [ + { + "pubkey": "1111111111111111111111111111111111111111111111111111111111111111", + "name": "Logan" + }, + { + "pubkey": "2222222222222222222222222222222222222222222222222222222222222222", + "name": "Wes" + }, + { + "pubkey": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "name": "Honey", + "isAgent": true, + "ownerPubkey": "1111111111111111111111111111111111111111111111111111111111111111" + }, + { + "pubkey": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "name": "Honey", + "isAgent": true, + "ownerPubkey": "1111111111111111111111111111111111111111111111111111111111111111" + } + ], + "viewer": "1111111111111111111111111111111111111111111111111111111111111111", + "candidates": [ + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + ], + "expected": { + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa": { + "name": "Honey · rcaj", + "qualifier": "rcaj" + }, + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb": { + "name": "Honey · 04hu", + "qualifier": "04hu" + } + } + }, + { + "name": "other_same_owner_duplicates", + "identities": [ + { + "pubkey": "1111111111111111111111111111111111111111111111111111111111111111", + "name": "Logan" + }, + { + "pubkey": "2222222222222222222222222222222222222222222222222222222222222222", + "name": "Wes" + }, + { + "pubkey": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "name": "Honey", + "isAgent": true, + "ownerPubkey": "2222222222222222222222222222222222222222222222222222222222222222" + }, + { + "pubkey": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "name": "Honey", + "isAgent": true, + "ownerPubkey": "2222222222222222222222222222222222222222222222222222222222222222" + } + ], + "viewer": "1111111111111111111111111111111111111111111111111111111111111111", + "candidates": [ + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + ], + "expected": { + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa": { + "name": "Wes’s Honey · rcaj", + "qualifier": "rcaj" + }, + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb": { + "name": "Wes’s Honey · 04hu", + "qualifier": "04hu" + } + } + }, + { + "name": "human_and_my_duplicates", + "identities": [ + { + "pubkey": "1111111111111111111111111111111111111111111111111111111111111111", + "name": "Logan" + }, + { + "pubkey": "2222222222222222222222222222222222222222222222222222222222222222", + "name": "Wes" + }, + { + "pubkey": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "name": "Honey" + }, + { + "pubkey": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "name": "Honey", + "isAgent": true, + "ownerPubkey": "1111111111111111111111111111111111111111111111111111111111111111" + }, + { + "pubkey": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "name": "Honey", + "isAgent": true, + "ownerPubkey": "1111111111111111111111111111111111111111111111111111111111111111" + } + ], + "viewer": "1111111111111111111111111111111111111111111111111111111111111111", + "candidates": [ + "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + ], + "expected": { + "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc": { + "name": "Honey", + "qualifier": null + }, + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa": { + "name": "Honey (agent) · rcaj", + "qualifier": "rcaj" + }, + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb": { + "name": "Honey (agent) · 04hu", + "qualifier": "04hu" + } + } + }, + { + "name": "viewer_wins_human_collision", + "identities": [ + { + "pubkey": "1111111111111111111111111111111111111111111111111111111111111111", + "name": "Logan" + }, + { + "pubkey": "2222222222222222222222222222222222222222222222222222222222222222", + "name": "Wes" + }, + { + "pubkey": "1111111111111111111111111111111111111111111111111111111111111111", + "name": "Honey" + }, + { + "pubkey": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "name": "Honey" + } + ], + "viewer": "1111111111111111111111111111111111111111111111111111111111111111", + "candidates": [ + "1111111111111111111111111111111111111111111111111111111111111111", + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + ], + "expected": { + "1111111111111111111111111111111111111111111111111111111111111111": { + "name": "Honey", + "qualifier": null + }, + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb": { + "name": "Honey · 04hu", + "qualifier": "04hu" + } + } + }, + { + "name": "equal_priority_humans", + "identities": [ + { + "pubkey": "1111111111111111111111111111111111111111111111111111111111111111", + "name": "Logan" + }, + { + "pubkey": "2222222222222222222222222222222222222222222222222222222222222222", + "name": "Wes" + }, + { + "pubkey": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "name": "Honey" + }, + { + "pubkey": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "name": "Honey" + } + ], + "viewer": "1111111111111111111111111111111111111111111111111111111111111111", + "candidates": [ + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + ], + "expected": { + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa": { + "name": "Honey · rcaj", + "qualifier": "rcaj" + }, + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb": { + "name": "Honey · 04hu", + "qualifier": "04hu" + } + } + }, + { + "name": "literal_owner_prefix", + "identities": [ + { + "pubkey": "1111111111111111111111111111111111111111111111111111111111111111", + "name": "Logan" + }, + { + "pubkey": "2222222222222222222222222222222222222222222222222222222222222222", + "name": "Wes" + }, + { + "pubkey": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "name": "Honey", + "isAgent": true, + "ownerPubkey": "1111111111111111111111111111111111111111111111111111111111111111" + }, + { + "pubkey": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "name": "Honey", + "isAgent": true, + "ownerPubkey": "2222222222222222222222222222222222222222222222222222222222222222" + }, + { + "pubkey": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "name": "Wes’s Honey" + } + ], + "viewer": "1111111111111111111111111111111111111111111111111111111111111111", + "candidates": [ + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc" + ], + "expected": { + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa": { + "name": "Honey", + "qualifier": null + }, + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb": { + "name": "Wes’s Honey · 04hu", + "qualifier": "04hu" + }, + "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc": { + "name": "Wes’s Honey", + "qualifier": null + } + } + }, + { + "name": "literal_agent_marker", + "identities": [ + { + "pubkey": "1111111111111111111111111111111111111111111111111111111111111111", + "name": "Logan" + }, + { + "pubkey": "2222222222222222222222222222222222222222222222222222222222222222", + "name": "Wes" + }, + { + "pubkey": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "name": "Honey" + }, + { + "pubkey": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "name": "Honey", + "isAgent": true, + "ownerPubkey": "1111111111111111111111111111111111111111111111111111111111111111" + }, + { + "pubkey": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "name": "Honey (agent)" + } + ], + "viewer": "1111111111111111111111111111111111111111111111111111111111111111", + "candidates": [ + "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + ], + "expected": { + "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc": { + "name": "Honey", + "qualifier": null + }, + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa": { + "name": "Honey (agent) · rcaj", + "qualifier": "rcaj" + }, + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb": { + "name": "Honey (agent)", + "qualifier": null + } + } + }, + { + "name": "defer_suffix_when_another_row_qualifies", + "identities": [ + { + "pubkey": "1111111111111111111111111111111111111111111111111111111111111111", + "name": "Logan" + }, + { + "pubkey": "2222222222222222222222222222222222222222222222222222222222222222", + "name": "Wes" + }, + { + "pubkey": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "name": "Honey", + "isAgent": true + }, + { + "pubkey": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "name": "Honey", + "isAgent": true, + "ownerPubkey": "2222222222222222222222222222222222222222222222222222222222222222" + } + ], + "viewer": "1111111111111111111111111111111111111111111111111111111111111111", + "candidates": [ + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + ], + "expected": { + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa": { + "name": "Honey", + "qualifier": null + }, + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb": { + "name": "Wes’s Honey", + "qualifier": null + } + } + }, + { + "name": "unknown_owners", + "identities": [ + { + "pubkey": "1111111111111111111111111111111111111111111111111111111111111111", + "name": "Logan" + }, + { + "pubkey": "2222222222222222222222222222222222222222222222222222222222222222", + "name": "Wes" + }, + { + "pubkey": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "name": "Honey", + "isAgent": true + }, + { + "pubkey": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "name": "Honey", + "isAgent": true + } + ], + "viewer": "1111111111111111111111111111111111111111111111111111111111111111", + "candidates": [ + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + ], + "expected": { + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa": { + "name": "Honey · rcaj", + "qualifier": "rcaj" + }, + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb": { + "name": "Honey · 04hu", + "qualifier": "04hu" + } + } + }, + { + "name": "unknown_viewer_is_not_mine", + "identities": [ + { + "pubkey": "1111111111111111111111111111111111111111111111111111111111111111", + "name": "Logan" + }, + { + "pubkey": "2222222222222222222222222222222222222222222222222222222222222222", + "name": "Wes" + }, + { + "pubkey": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "name": "Honey", + "isAgent": true + }, + { + "pubkey": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "name": "Honey", + "isAgent": true, + "ownerPubkey": "2222222222222222222222222222222222222222222222222222222222222222" + } + ], + "candidates": [ + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + ], + "expected": { + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa": { + "name": "Honey", + "qualifier": null + }, + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb": { + "name": "Wes’s Honey", + "qualifier": null + } + } + }, + { + "name": "missing_owner_name", + "identities": [ + { + "pubkey": "1111111111111111111111111111111111111111111111111111111111111111", + "name": "Logan" + }, + { + "pubkey": "2222222222222222222222222222222222222222222222222222222222222222", + "name": "Wes" + }, + { + "pubkey": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "name": "Honey", + "isAgent": true, + "ownerPubkey": "3333333333333333333333333333333333333333333333333333333333333333" + }, + { + "pubkey": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "name": "Honey", + "isAgent": true, + "ownerPubkey": "3333333333333333333333333333333333333333333333333333333333333333" + } + ], + "viewer": "1111111111111111111111111111111111111111111111111111111111111111", + "candidates": [ + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + ], + "expected": { + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa": { + "name": "Honey · rcaj", + "qualifier": "rcaj" + }, + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb": { + "name": "Honey · 04hu", + "qualifier": "04hu" + } + } + }, + { + "name": "duplicate_owner_names", + "identities": [ + { + "pubkey": "1111111111111111111111111111111111111111111111111111111111111111", + "name": "Logan" + }, + { + "pubkey": "2222222222222222222222222222222222222222222222222222222222222222", + "name": "Wes" + }, + { + "pubkey": "3333333333333333333333333333333333333333333333333333333333333333", + "name": "Wes" + }, + { + "pubkey": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "name": "Honey", + "isAgent": true, + "ownerPubkey": "2222222222222222222222222222222222222222222222222222222222222222" + }, + { + "pubkey": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "name": "Honey", + "isAgent": true, + "ownerPubkey": "3333333333333333333333333333333333333333333333333333333333333333" + } + ], + "viewer": "1111111111111111111111111111111111111111111111111111111111111111", + "candidates": [ + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + ], + "expected": { + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa": { + "name": "Wes’s Honey · rcaj", + "qualifier": "rcaj" + }, + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb": { + "name": "Wes’s Honey · 04hu", + "qualifier": "04hu" + } + } + }, + { + "name": "extend_only_colliding_suffixes", + "identities": [ + { + "pubkey": "1111111111111111111111111111111111111111111111111111111111111111", + "name": "Logan" + }, + { + "pubkey": "2222222222222222222222222222222222222222222222222222222222222222", + "name": "Wes" + }, + { + "pubkey": "3ee51d04715939ef0e492f7b87bf1dcaf2c0b4a16b753f19d7a92e96ba01b8db", + "name": "Honey", + "isAgent": true, + "ownerPubkey": "1111111111111111111111111111111111111111111111111111111111111111" + }, + { + "pubkey": "55e2be15c0fb4ba8231701c4ba65d545715b7c79761952fd8a7cc75cf6afb602", + "name": "Honey", + "isAgent": true, + "ownerPubkey": "1111111111111111111111111111111111111111111111111111111111111111" + }, + { + "pubkey": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "name": "Honey", + "isAgent": true, + "ownerPubkey": "1111111111111111111111111111111111111111111111111111111111111111" + } + ], + "viewer": "1111111111111111111111111111111111111111111111111111111111111111", + "candidates": [ + "3ee51d04715939ef0e492f7b87bf1dcaf2c0b4a16b753f19d7a92e96ba01b8db", + "55e2be15c0fb4ba8231701c4ba65d545715b7c79761952fd8a7cc75cf6afb602", + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + ], + "expected": { + "3ee51d04715939ef0e492f7b87bf1dcaf2c0b4a16b753f19d7a92e96ba01b8db": { + "name": "Honey · suscycq", + "qualifier": "suscycq" + }, + "55e2be15c0fb4ba8231701c4ba65d545715b7c79761952fd8a7cc75cf6afb602": { + "name": "Honey · quscycq", + "qualifier": "quscycq" + }, + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa": { + "name": "Honey · rcaj", + "qualifier": "rcaj" + } + } + }, + { + "name": "literal_suffix_keeps_human_priority", + "identities": [ + { + "pubkey": "1111111111111111111111111111111111111111111111111111111111111111", + "name": "Logan" + }, + { + "pubkey": "2222222222222222222222222222222222222222222222222222222222222222", + "name": "Wes" + }, + { + "pubkey": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "name": "Honey", + "isAgent": true, + "ownerPubkey": "1111111111111111111111111111111111111111111111111111111111111111" + }, + { + "pubkey": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "name": "Honey", + "isAgent": true, + "ownerPubkey": "1111111111111111111111111111111111111111111111111111111111111111" + }, + { + "pubkey": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "name": "Honey · rcaj" + } + ], + "viewer": "1111111111111111111111111111111111111111111111111111111111111111", + "candidates": [ + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc" + ], + "expected": { + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa": { + "name": "Honey · mrcaj", + "qualifier": "mrcaj" + }, + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb": { + "name": "Honey · 04hu", + "qualifier": "04hu" + }, + "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc": { + "name": "Honey · rcaj", + "qualifier": null + } + } + }, + { + "name": "trim_but_no_case_fold", + "identities": [ + { + "pubkey": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "name": " Honey " + }, + { + "pubkey": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "name": "honey" + } + ], + "viewer": "1111111111111111111111111111111111111111111111111111111111111111", + "candidates": [ + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + ], + "expected": { + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa": { + "name": "Honey", + "qualifier": null + }, + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb": { + "name": "honey", + "qualifier": null + } + } + }, + { + "name": "trim_creates_collision", + "identities": [ + { + "pubkey": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "name": " Honey " + }, + { + "pubkey": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "name": "Honey" + } + ], + "viewer": "1111111111111111111111111111111111111111111111111111111111111111", + "candidates": [ + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + ], + "expected": { + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa": { + "name": "Honey · rcaj", + "qualifier": "rcaj" + }, + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb": { + "name": "Honey · 04hu", + "qualifier": "04hu" + } + } + }, + { + "name": "no_unicode_normalization", + "identities": [ + { + "pubkey": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "name": "é" + }, + { + "pubkey": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "name": "é" + } + ], + "viewer": "1111111111111111111111111111111111111111111111111111111111111111", + "candidates": [ + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + ], + "expected": { + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa": { + "name": "é", + "qualifier": null + }, + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb": { + "name": "é", + "qualifier": null + } + } + }, + { + "name": "normalize_keys_and_deduplicate", + "identities": [ + { + "pubkey": "1111111111111111111111111111111111111111111111111111111111111111", + "name": "Logan" + }, + { + "pubkey": "2222222222222222222222222222222222222222222222222222222222222222", + "name": "Wes" + }, + { + "pubkey": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "name": "Viewer" + }, + { + "pubkey": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "name": "Honey", + "isAgent": true, + "ownerPubkey": "CCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC" + }, + { + "pubkey": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", + "name": "Honey", + "isAgent": true, + "ownerPubkey": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc" + }, + { + "pubkey": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "name": "Honey", + "isAgent": true, + "ownerPubkey": "2222222222222222222222222222222222222222222222222222222222222222" + } + ], + "viewer": "CCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC", + "candidates": [ + "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", + "BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB" + ], + "expected": { + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa": { + "name": "Honey", + "qualifier": null + }, + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb": { + "name": "Wes’s Honey", + "qualifier": null + } + } + }, + { + "name": "unselected_namesake_does_not_compete", + "identities": [ + { + "pubkey": "1111111111111111111111111111111111111111111111111111111111111111", + "name": "Logan" + }, + { + "pubkey": "2222222222222222222222222222222222222222222222222222222222222222", + "name": "Wes" + }, + { + "pubkey": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "name": "Honey", + "isAgent": true, + "ownerPubkey": "1111111111111111111111111111111111111111111111111111111111111111" + }, + { + "pubkey": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "name": "Honey", + "isAgent": true, + "ownerPubkey": "1111111111111111111111111111111111111111111111111111111111111111" + } + ], + "viewer": "1111111111111111111111111111111111111111111111111111111111111111", + "candidates": [ + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + ], + "expected": { + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa": { + "name": "Honey", + "qualifier": null + } + } + }, + { + "name": "empty_candidate_set", + "identities": [ + { + "pubkey": "1111111111111111111111111111111111111111111111111111111111111111", + "name": "Logan" + }, + { + "pubkey": "2222222222222222222222222222222222222222222222222222222222222222", + "name": "Wes" + }, + { + "pubkey": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "name": "Honey", + "isAgent": true, + "ownerPubkey": "1111111111111111111111111111111111111111111111111111111111111111" + } + ], + "viewer": "1111111111111111111111111111111111111111111111111111111111111111", + "candidates": [], + "expected": {} + }, + { + "name": "absent_candidate_fact", + "identities": [ + { + "pubkey": "1111111111111111111111111111111111111111111111111111111111111111", + "name": "Logan" + }, + { + "pubkey": "2222222222222222222222222222222222222222222222222222222222222222", + "name": "Wes" + }, + { + "pubkey": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "name": "Honey", + "isAgent": true, + "ownerPubkey": "1111111111111111111111111111111111111111111111111111111111111111" + } + ], + "viewer": "1111111111111111111111111111111111111111111111111111111111111111", + "candidates": [ + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + ], + "expected": {} + }, + { + "name": "omitted_candidates_selects_all", + "identities": [ + { + "pubkey": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "name": "Honey" + }, + { + "pubkey": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "name": "Honey" + } + ], + "viewer": "1111111111111111111111111111111111111111111111111111111111111111", + "expected": { + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa": { + "name": "Honey · rcaj", + "qualifier": "rcaj" + }, + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb": { + "name": "Honey · 04hu", + "qualifier": "04hu" + } + } + }, + { + "name": "all_aliases_compete_last_alias_returned", + "identities": [ + { + "pubkey": "1111111111111111111111111111111111111111111111111111111111111111", + "name": "Logan" + }, + { + "pubkey": "2222222222222222222222222222222222222222222222222222222222222222", + "name": "Wes" + }, + { + "pubkey": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "name": "Honey", + "isAgent": true, + "ownerPubkey": "1111111111111111111111111111111111111111111111111111111111111111" + }, + { + "pubkey": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "name": "Honey", + "isAgent": true, + "ownerPubkey": "1111111111111111111111111111111111111111111111111111111111111111" + }, + { + "pubkey": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "name": "Juniper", + "isAgent": true, + "ownerPubkey": "1111111111111111111111111111111111111111111111111111111111111111" + }, + { + "pubkey": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "name": "Juniper", + "isAgent": true, + "ownerPubkey": "1111111111111111111111111111111111111111111111111111111111111111" + } + ], + "viewer": "1111111111111111111111111111111111111111111111111111111111111111", + "candidates": [ + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + ], + "expected": { + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa": { + "name": "Juniper · rcaj", + "qualifier": "rcaj" + }, + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb": { + "name": "Juniper · 04hu", + "qualifier": "04hu" + } + } + }, + { + "name": "one_key_aliases_are_not_competitors", + "identities": [ + { + "pubkey": "1111111111111111111111111111111111111111111111111111111111111111", + "name": "Logan" + }, + { + "pubkey": "2222222222222222222222222222222222222222222222222222222222222222", + "name": "Wes" + }, + { + "pubkey": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "name": "Honey", + "isAgent": true, + "ownerPubkey": "1111111111111111111111111111111111111111111111111111111111111111" + }, + { + "pubkey": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "name": "Juniper", + "isAgent": true, + "ownerPubkey": "1111111111111111111111111111111111111111111111111111111111111111" + }, + { + "pubkey": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "name": "Honey", + "isAgent": true, + "ownerPubkey": "1111111111111111111111111111111111111111111111111111111111111111" + } + ], + "viewer": "1111111111111111111111111111111111111111111111111111111111111111", + "candidates": [ + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + ], + "expected": { + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa": { + "name": "Honey", + "qualifier": null + } + } + }, + { + "name": "nonreturned_literal_alias_still_competes", + "identities": [ + { + "pubkey": "1111111111111111111111111111111111111111111111111111111111111111", + "name": "Logan" + }, + { + "pubkey": "2222222222222222222222222222222222222222222222222222222222222222", + "name": "Wes" + }, + { + "pubkey": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "name": "Honey", + "isAgent": true, + "ownerPubkey": "1111111111111111111111111111111111111111111111111111111111111111" + }, + { + "pubkey": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "name": "Honey", + "isAgent": true, + "ownerPubkey": "1111111111111111111111111111111111111111111111111111111111111111" + }, + { + "pubkey": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "name": "Honey · rcaj" + }, + { + "pubkey": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "name": "Juniper" + } + ], + "viewer": "1111111111111111111111111111111111111111111111111111111111111111", + "candidates": [ + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc" + ], + "expected": { + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa": { + "name": "Honey · mrcaj", + "qualifier": "mrcaj" + }, + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb": { + "name": "Honey · 04hu", + "qualifier": "04hu" + }, + "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc": { + "name": "Juniper", + "qualifier": null + } + } + }, + { + "name": "preferred_owner_alias_is_raw", + "identities": [ + { + "pubkey": "1111111111111111111111111111111111111111111111111111111111111111", + "name": "Logan" + }, + { + "pubkey": "2222222222222222222222222222222222222222222222222222222222222222", + "name": "Wes" + }, + { + "pubkey": "2222222222222222222222222222222222222222222222222222222222222222", + "name": "James" + }, + { + "pubkey": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "name": "Honey", + "isAgent": true, + "ownerPubkey": "1111111111111111111111111111111111111111111111111111111111111111" + }, + { + "pubkey": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "name": "Honey", + "isAgent": true, + "ownerPubkey": "2222222222222222222222222222222222222222222222222222222222222222" + } + ], + "viewer": "1111111111111111111111111111111111111111111111111111111111111111", + "candidates": [ + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + ], + "expected": { + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa": { + "name": "Honey", + "qualifier": null + }, + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb": { + "name": "James’s Honey", + "qualifier": null + } + } + }, + { + "name": "full_key_and_counters_occupied", + "identities": [ + { + "pubkey": "1111111111111111111111111111111111111111111111111111111111111111", + "name": "Logan" + }, + { + "pubkey": "2222222222222222222222222222222222222222222222222222222222222222", + "name": "Wes" + }, + { + "pubkey": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "name": "Honey", + "isAgent": true, + "ownerPubkey": "1111111111111111111111111111111111111111111111111111111111111111" + }, + { + "pubkey": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "name": "Honey", + "isAgent": true, + "ownerPubkey": "1111111111111111111111111111111111111111111111111111111111111111" + }, + { + "pubkey": "0000000000000000000000000000000000000000000000000000000000000010", + "name": "Honey · rcaj" + }, + { + "pubkey": "0000000000000000000000000000000000000000000000000000000000000011", + "name": "Honey · mrcaj" + }, + { + "pubkey": "0000000000000000000000000000000000000000000000000000000000000012", + "name": "Honey · amrcaj" + }, + { + "pubkey": "0000000000000000000000000000000000000000000000000000000000000013", + "name": "Honey · qamrcaj" + }, + { + "pubkey": "0000000000000000000000000000000000000000000000000000000000000014", + "name": "Honey · 4qamrcaj" + }, + { + "pubkey": "0000000000000000000000000000000000000000000000000000000000000015", + "name": "Honey · 24qamrcaj" + }, + { + "pubkey": "0000000000000000000000000000000000000000000000000000000000000016", + "name": "Honey · 424qamrcaj" + }, + { + "pubkey": "0000000000000000000000000000000000000000000000000000000000000017", + "name": "Honey · 2424qamrcaj" + }, + { + "pubkey": "0000000000000000000000000000000000000000000000000000000000000018", + "name": "Honey · 42424qamrcaj" + }, + { + "pubkey": "0000000000000000000000000000000000000000000000000000000000000019", + "name": "Honey · 242424qamrcaj" + }, + { + "pubkey": "000000000000000000000000000000000000000000000000000000000000001a", + "name": "Honey · 4242424qamrcaj" + }, + { + "pubkey": "000000000000000000000000000000000000000000000000000000000000001b", + "name": "Honey · 24242424qamrcaj" + }, + { + "pubkey": "000000000000000000000000000000000000000000000000000000000000001c", + "name": "Honey · 424242424qamrcaj" + }, + { + "pubkey": "000000000000000000000000000000000000000000000000000000000000001d", + "name": "Honey · 2424242424qamrcaj" + }, + { + "pubkey": "000000000000000000000000000000000000000000000000000000000000001e", + "name": "Honey · 42424242424qamrcaj" + }, + { + "pubkey": "000000000000000000000000000000000000000000000000000000000000001f", + "name": "Honey · 242424242424qamrcaj" + }, + { + "pubkey": "0000000000000000000000000000000000000000000000000000000000000020", + "name": "Honey · 4242424242424qamrcaj" + }, + { + "pubkey": "0000000000000000000000000000000000000000000000000000000000000021", + "name": "Honey · 24242424242424qamrcaj" + }, + { + "pubkey": "0000000000000000000000000000000000000000000000000000000000000022", + "name": "Honey · 424242424242424qamrcaj" + }, + { + "pubkey": "0000000000000000000000000000000000000000000000000000000000000023", + "name": "Honey · 2424242424242424qamrcaj" + }, + { + "pubkey": "0000000000000000000000000000000000000000000000000000000000000024", + "name": "Honey · 42424242424242424qamrcaj" + }, + { + "pubkey": "0000000000000000000000000000000000000000000000000000000000000025", + "name": "Honey · 242424242424242424qamrcaj" + }, + { + "pubkey": "0000000000000000000000000000000000000000000000000000000000000026", + "name": "Honey · 4242424242424242424qamrcaj" + }, + { + "pubkey": "0000000000000000000000000000000000000000000000000000000000000027", + "name": "Honey · 24242424242424242424qamrcaj" + }, + { + "pubkey": "0000000000000000000000000000000000000000000000000000000000000028", + "name": "Honey · 424242424242424242424qamrcaj" + }, + { + "pubkey": "0000000000000000000000000000000000000000000000000000000000000029", + "name": "Honey · 2424242424242424242424qamrcaj" + }, + { + "pubkey": "000000000000000000000000000000000000000000000000000000000000002a", + "name": "Honey · 42424242424242424242424qamrcaj" + }, + { + "pubkey": "000000000000000000000000000000000000000000000000000000000000002b", + "name": "Honey · 242424242424242424242424qamrcaj" + }, + { + "pubkey": "000000000000000000000000000000000000000000000000000000000000002c", + "name": "Honey · 4242424242424242424242424qamrcaj" + }, + { + "pubkey": "000000000000000000000000000000000000000000000000000000000000002d", + "name": "Honey · 24242424242424242424242424qamrcaj" + }, + { + "pubkey": "000000000000000000000000000000000000000000000000000000000000002e", + "name": "Honey · 424242424242424242424242424qamrcaj" + }, + { + "pubkey": "000000000000000000000000000000000000000000000000000000000000002f", + "name": "Honey · 2424242424242424242424242424qamrcaj" + }, + { + "pubkey": "0000000000000000000000000000000000000000000000000000000000000030", + "name": "Honey · 42424242424242424242424242424qamrcaj" + }, + { + "pubkey": "0000000000000000000000000000000000000000000000000000000000000031", + "name": "Honey · 242424242424242424242424242424qamrcaj" + }, + { + "pubkey": "0000000000000000000000000000000000000000000000000000000000000032", + "name": "Honey · 4242424242424242424242424242424qamrcaj" + }, + { + "pubkey": "0000000000000000000000000000000000000000000000000000000000000033", + "name": "Honey · 24242424242424242424242424242424qamrcaj" + }, + { + "pubkey": "0000000000000000000000000000000000000000000000000000000000000034", + "name": "Honey · 424242424242424242424242424242424qamrcaj" + }, + { + "pubkey": "0000000000000000000000000000000000000000000000000000000000000035", + "name": "Honey · 2424242424242424242424242424242424qamrcaj" + }, + { + "pubkey": "0000000000000000000000000000000000000000000000000000000000000036", + "name": "Honey · 42424242424242424242424242424242424qamrcaj" + }, + { + "pubkey": "0000000000000000000000000000000000000000000000000000000000000037", + "name": "Honey · 242424242424242424242424242424242424qamrcaj" + }, + { + "pubkey": "0000000000000000000000000000000000000000000000000000000000000038", + "name": "Honey · 4242424242424242424242424242424242424qamrcaj" + }, + { + "pubkey": "0000000000000000000000000000000000000000000000000000000000000039", + "name": "Honey · 24242424242424242424242424242424242424qamrcaj" + }, + { + "pubkey": "000000000000000000000000000000000000000000000000000000000000003a", + "name": "Honey · 424242424242424242424242424242424242424qamrcaj" + }, + { + "pubkey": "000000000000000000000000000000000000000000000000000000000000003b", + "name": "Honey · 2424242424242424242424242424242424242424qamrcaj" + }, + { + "pubkey": "000000000000000000000000000000000000000000000000000000000000003c", + "name": "Honey · 42424242424242424242424242424242424242424qamrcaj" + }, + { + "pubkey": "000000000000000000000000000000000000000000000000000000000000003d", + "name": "Honey · 242424242424242424242424242424242424242424qamrcaj" + }, + { + "pubkey": "000000000000000000000000000000000000000000000000000000000000003e", + "name": "Honey · 4242424242424242424242424242424242424242424qamrcaj" + }, + { + "pubkey": "000000000000000000000000000000000000000000000000000000000000003f", + "name": "Honey · 24242424242424242424242424242424242424242424qamrcaj" + }, + { + "pubkey": "0000000000000000000000000000000000000000000000000000000000000040", + "name": "Honey · 424242424242424242424242424242424242424242424qamrcaj" + }, + { + "pubkey": "0000000000000000000000000000000000000000000000000000000000000041", + "name": "Honey · 2424242424242424242424242424242424242424242424qamrcaj" + }, + { + "pubkey": "0000000000000000000000000000000000000000000000000000000000000042", + "name": "Honey · 42424242424242424242424242424242424242424242424qamrcaj" + }, + { + "pubkey": "0000000000000000000000000000000000000000000000000000000000000043", + "name": "Honey · 242424242424242424242424242424242424242424242424qamrcaj" + }, + { + "pubkey": "0000000000000000000000000000000000000000000000000000000000000044", + "name": "Honey · 4242424242424242424242424242424242424242424242424qamrcaj" + }, + { + "pubkey": "0000000000000000000000000000000000000000000000000000000000000045", + "name": "Honey · 24242424242424242424242424242424242424242424242424qamrcaj" + }, + { + "pubkey": "0000000000000000000000000000000000000000000000000000000000000046", + "name": "Honey · 424242424242424242424242424242424242424242424242424qamrcaj" + }, + { + "pubkey": "0000000000000000000000000000000000000000000000000000000000000047", + "name": "Honey · 1424242424242424242424242424242424242424242424242424qamrcaj" + }, + { + "pubkey": "0000000000000000000000000000000000000000000000000000000000000048", + "name": "Honey · b1424242424242424242424242424242424242424242424242424qamrcaj" + }, + { + "pubkey": "0000000000000000000000000000000000000000000000000000000000000049", + "name": "Honey · ub1424242424242424242424242424242424242424242424242424qamrcaj" + }, + { + "pubkey": "000000000000000000000000000000000000000000000000000000000000004a", + "name": "Honey · pub1424242424242424242424242424242424242424242424242424qamrcaj" + }, + { + "pubkey": "000000000000000000000000000000000000000000000000000000000000004b", + "name": "Honey · npub1424242424242424242424242424242424242424242424242424qamrcaj" + }, + { + "pubkey": "000000000000000000000000000000000000000000000000000000000000004c", + "name": "Honey · npub1424242424242424242424242424242424242424242424242424qamrcaj · 1" + }, + { + "pubkey": "000000000000000000000000000000000000000000000000000000000000004d", + "name": "Honey · npub1424242424242424242424242424242424242424242424242424qamrcaj · 2" + } + ], + "viewer": "1111111111111111111111111111111111111111111111111111111111111111", + "candidates": [ + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "0000000000000000000000000000000000000000000000000000000000000010", + "0000000000000000000000000000000000000000000000000000000000000011", + "0000000000000000000000000000000000000000000000000000000000000012", + "0000000000000000000000000000000000000000000000000000000000000013", + "0000000000000000000000000000000000000000000000000000000000000014", + "0000000000000000000000000000000000000000000000000000000000000015", + "0000000000000000000000000000000000000000000000000000000000000016", + "0000000000000000000000000000000000000000000000000000000000000017", + "0000000000000000000000000000000000000000000000000000000000000018", + "0000000000000000000000000000000000000000000000000000000000000019", + "000000000000000000000000000000000000000000000000000000000000001a", + "000000000000000000000000000000000000000000000000000000000000001b", + "000000000000000000000000000000000000000000000000000000000000001c", + "000000000000000000000000000000000000000000000000000000000000001d", + "000000000000000000000000000000000000000000000000000000000000001e", + "000000000000000000000000000000000000000000000000000000000000001f", + "0000000000000000000000000000000000000000000000000000000000000020", + "0000000000000000000000000000000000000000000000000000000000000021", + "0000000000000000000000000000000000000000000000000000000000000022", + "0000000000000000000000000000000000000000000000000000000000000023", + "0000000000000000000000000000000000000000000000000000000000000024", + "0000000000000000000000000000000000000000000000000000000000000025", + "0000000000000000000000000000000000000000000000000000000000000026", + "0000000000000000000000000000000000000000000000000000000000000027", + "0000000000000000000000000000000000000000000000000000000000000028", + "0000000000000000000000000000000000000000000000000000000000000029", + "000000000000000000000000000000000000000000000000000000000000002a", + "000000000000000000000000000000000000000000000000000000000000002b", + "000000000000000000000000000000000000000000000000000000000000002c", + "000000000000000000000000000000000000000000000000000000000000002d", + "000000000000000000000000000000000000000000000000000000000000002e", + "000000000000000000000000000000000000000000000000000000000000002f", + "0000000000000000000000000000000000000000000000000000000000000030", + "0000000000000000000000000000000000000000000000000000000000000031", + "0000000000000000000000000000000000000000000000000000000000000032", + "0000000000000000000000000000000000000000000000000000000000000033", + "0000000000000000000000000000000000000000000000000000000000000034", + "0000000000000000000000000000000000000000000000000000000000000035", + "0000000000000000000000000000000000000000000000000000000000000036", + "0000000000000000000000000000000000000000000000000000000000000037", + "0000000000000000000000000000000000000000000000000000000000000038", + "0000000000000000000000000000000000000000000000000000000000000039", + "000000000000000000000000000000000000000000000000000000000000003a", + "000000000000000000000000000000000000000000000000000000000000003b", + "000000000000000000000000000000000000000000000000000000000000003c", + "000000000000000000000000000000000000000000000000000000000000003d", + "000000000000000000000000000000000000000000000000000000000000003e", + "000000000000000000000000000000000000000000000000000000000000003f", + "0000000000000000000000000000000000000000000000000000000000000040", + "0000000000000000000000000000000000000000000000000000000000000041", + "0000000000000000000000000000000000000000000000000000000000000042", + "0000000000000000000000000000000000000000000000000000000000000043", + "0000000000000000000000000000000000000000000000000000000000000044", + "0000000000000000000000000000000000000000000000000000000000000045", + "0000000000000000000000000000000000000000000000000000000000000046", + "0000000000000000000000000000000000000000000000000000000000000047", + "0000000000000000000000000000000000000000000000000000000000000048", + "0000000000000000000000000000000000000000000000000000000000000049", + "000000000000000000000000000000000000000000000000000000000000004a", + "000000000000000000000000000000000000000000000000000000000000004b", + "000000000000000000000000000000000000000000000000000000000000004c", + "000000000000000000000000000000000000000000000000000000000000004d" + ], + "expected": { + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa": { + "name": "Honey · npub1424242424242424242424242424242424242424242424242424qamrcaj · 3", + "qualifier": "npub1424242424242424242424242424242424242424242424242424qamrcaj · 3" + }, + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb": { + "name": "Honey · 04hu", + "qualifier": "04hu" + }, + "0000000000000000000000000000000000000000000000000000000000000010": { + "name": "Honey · rcaj", + "qualifier": null + }, + "0000000000000000000000000000000000000000000000000000000000000011": { + "name": "Honey · mrcaj", + "qualifier": null + }, + "0000000000000000000000000000000000000000000000000000000000000012": { + "name": "Honey · amrcaj", + "qualifier": null + }, + "0000000000000000000000000000000000000000000000000000000000000013": { + "name": "Honey · qamrcaj", + "qualifier": null + }, + "0000000000000000000000000000000000000000000000000000000000000014": { + "name": "Honey · 4qamrcaj", + "qualifier": null + }, + "0000000000000000000000000000000000000000000000000000000000000015": { + "name": "Honey · 24qamrcaj", + "qualifier": null + }, + "0000000000000000000000000000000000000000000000000000000000000016": { + "name": "Honey · 424qamrcaj", + "qualifier": null + }, + "0000000000000000000000000000000000000000000000000000000000000017": { + "name": "Honey · 2424qamrcaj", + "qualifier": null + }, + "0000000000000000000000000000000000000000000000000000000000000018": { + "name": "Honey · 42424qamrcaj", + "qualifier": null + }, + "0000000000000000000000000000000000000000000000000000000000000019": { + "name": "Honey · 242424qamrcaj", + "qualifier": null + }, + "000000000000000000000000000000000000000000000000000000000000001a": { + "name": "Honey · 4242424qamrcaj", + "qualifier": null + }, + "000000000000000000000000000000000000000000000000000000000000001b": { + "name": "Honey · 24242424qamrcaj", + "qualifier": null + }, + "000000000000000000000000000000000000000000000000000000000000001c": { + "name": "Honey · 424242424qamrcaj", + "qualifier": null + }, + "000000000000000000000000000000000000000000000000000000000000001d": { + "name": "Honey · 2424242424qamrcaj", + "qualifier": null + }, + "000000000000000000000000000000000000000000000000000000000000001e": { + "name": "Honey · 42424242424qamrcaj", + "qualifier": null + }, + "000000000000000000000000000000000000000000000000000000000000001f": { + "name": "Honey · 242424242424qamrcaj", + "qualifier": null + }, + "0000000000000000000000000000000000000000000000000000000000000020": { + "name": "Honey · 4242424242424qamrcaj", + "qualifier": null + }, + "0000000000000000000000000000000000000000000000000000000000000021": { + "name": "Honey · 24242424242424qamrcaj", + "qualifier": null + }, + "0000000000000000000000000000000000000000000000000000000000000022": { + "name": "Honey · 424242424242424qamrcaj", + "qualifier": null + }, + "0000000000000000000000000000000000000000000000000000000000000023": { + "name": "Honey · 2424242424242424qamrcaj", + "qualifier": null + }, + "0000000000000000000000000000000000000000000000000000000000000024": { + "name": "Honey · 42424242424242424qamrcaj", + "qualifier": null + }, + "0000000000000000000000000000000000000000000000000000000000000025": { + "name": "Honey · 242424242424242424qamrcaj", + "qualifier": null + }, + "0000000000000000000000000000000000000000000000000000000000000026": { + "name": "Honey · 4242424242424242424qamrcaj", + "qualifier": null + }, + "0000000000000000000000000000000000000000000000000000000000000027": { + "name": "Honey · 24242424242424242424qamrcaj", + "qualifier": null + }, + "0000000000000000000000000000000000000000000000000000000000000028": { + "name": "Honey · 424242424242424242424qamrcaj", + "qualifier": null + }, + "0000000000000000000000000000000000000000000000000000000000000029": { + "name": "Honey · 2424242424242424242424qamrcaj", + "qualifier": null + }, + "000000000000000000000000000000000000000000000000000000000000002a": { + "name": "Honey · 42424242424242424242424qamrcaj", + "qualifier": null + }, + "000000000000000000000000000000000000000000000000000000000000002b": { + "name": "Honey · 242424242424242424242424qamrcaj", + "qualifier": null + }, + "000000000000000000000000000000000000000000000000000000000000002c": { + "name": "Honey · 4242424242424242424242424qamrcaj", + "qualifier": null + }, + "000000000000000000000000000000000000000000000000000000000000002d": { + "name": "Honey · 24242424242424242424242424qamrcaj", + "qualifier": null + }, + "000000000000000000000000000000000000000000000000000000000000002e": { + "name": "Honey · 424242424242424242424242424qamrcaj", + "qualifier": null + }, + "000000000000000000000000000000000000000000000000000000000000002f": { + "name": "Honey · 2424242424242424242424242424qamrcaj", + "qualifier": null + }, + "0000000000000000000000000000000000000000000000000000000000000030": { + "name": "Honey · 42424242424242424242424242424qamrcaj", + "qualifier": null + }, + "0000000000000000000000000000000000000000000000000000000000000031": { + "name": "Honey · 242424242424242424242424242424qamrcaj", + "qualifier": null + }, + "0000000000000000000000000000000000000000000000000000000000000032": { + "name": "Honey · 4242424242424242424242424242424qamrcaj", + "qualifier": null + }, + "0000000000000000000000000000000000000000000000000000000000000033": { + "name": "Honey · 24242424242424242424242424242424qamrcaj", + "qualifier": null + }, + "0000000000000000000000000000000000000000000000000000000000000034": { + "name": "Honey · 424242424242424242424242424242424qamrcaj", + "qualifier": null + }, + "0000000000000000000000000000000000000000000000000000000000000035": { + "name": "Honey · 2424242424242424242424242424242424qamrcaj", + "qualifier": null + }, + "0000000000000000000000000000000000000000000000000000000000000036": { + "name": "Honey · 42424242424242424242424242424242424qamrcaj", + "qualifier": null + }, + "0000000000000000000000000000000000000000000000000000000000000037": { + "name": "Honey · 242424242424242424242424242424242424qamrcaj", + "qualifier": null + }, + "0000000000000000000000000000000000000000000000000000000000000038": { + "name": "Honey · 4242424242424242424242424242424242424qamrcaj", + "qualifier": null + }, + "0000000000000000000000000000000000000000000000000000000000000039": { + "name": "Honey · 24242424242424242424242424242424242424qamrcaj", + "qualifier": null + }, + "000000000000000000000000000000000000000000000000000000000000003a": { + "name": "Honey · 424242424242424242424242424242424242424qamrcaj", + "qualifier": null + }, + "000000000000000000000000000000000000000000000000000000000000003b": { + "name": "Honey · 2424242424242424242424242424242424242424qamrcaj", + "qualifier": null + }, + "000000000000000000000000000000000000000000000000000000000000003c": { + "name": "Honey · 42424242424242424242424242424242424242424qamrcaj", + "qualifier": null + }, + "000000000000000000000000000000000000000000000000000000000000003d": { + "name": "Honey · 242424242424242424242424242424242424242424qamrcaj", + "qualifier": null + }, + "000000000000000000000000000000000000000000000000000000000000003e": { + "name": "Honey · 4242424242424242424242424242424242424242424qamrcaj", + "qualifier": null + }, + "000000000000000000000000000000000000000000000000000000000000003f": { + "name": "Honey · 24242424242424242424242424242424242424242424qamrcaj", + "qualifier": null + }, + "0000000000000000000000000000000000000000000000000000000000000040": { + "name": "Honey · 424242424242424242424242424242424242424242424qamrcaj", + "qualifier": null + }, + "0000000000000000000000000000000000000000000000000000000000000041": { + "name": "Honey · 2424242424242424242424242424242424242424242424qamrcaj", + "qualifier": null + }, + "0000000000000000000000000000000000000000000000000000000000000042": { + "name": "Honey · 42424242424242424242424242424242424242424242424qamrcaj", + "qualifier": null + }, + "0000000000000000000000000000000000000000000000000000000000000043": { + "name": "Honey · 242424242424242424242424242424242424242424242424qamrcaj", + "qualifier": null + }, + "0000000000000000000000000000000000000000000000000000000000000044": { + "name": "Honey · 4242424242424242424242424242424242424242424242424qamrcaj", + "qualifier": null + }, + "0000000000000000000000000000000000000000000000000000000000000045": { + "name": "Honey · 24242424242424242424242424242424242424242424242424qamrcaj", + "qualifier": null + }, + "0000000000000000000000000000000000000000000000000000000000000046": { + "name": "Honey · 424242424242424242424242424242424242424242424242424qamrcaj", + "qualifier": null + }, + "0000000000000000000000000000000000000000000000000000000000000047": { + "name": "Honey · 1424242424242424242424242424242424242424242424242424qamrcaj", + "qualifier": null + }, + "0000000000000000000000000000000000000000000000000000000000000048": { + "name": "Honey · b1424242424242424242424242424242424242424242424242424qamrcaj", + "qualifier": null + }, + "0000000000000000000000000000000000000000000000000000000000000049": { + "name": "Honey · ub1424242424242424242424242424242424242424242424242424qamrcaj", + "qualifier": null + }, + "000000000000000000000000000000000000000000000000000000000000004a": { + "name": "Honey · pub1424242424242424242424242424242424242424242424242424qamrcaj", + "qualifier": null + }, + "000000000000000000000000000000000000000000000000000000000000004b": { + "name": "Honey · npub1424242424242424242424242424242424242424242424242424qamrcaj", + "qualifier": null + }, + "000000000000000000000000000000000000000000000000000000000000004c": { + "name": "Honey · npub1424242424242424242424242424242424242424242424242424qamrcaj · 1", + "qualifier": null + }, + "000000000000000000000000000000000000000000000000000000000000004d": { + "name": "Honey · npub1424242424242424242424242424242424242424242424242424qamrcaj · 2", + "qualifier": null + } + } + } + ] +}