From 63555db48a8a276ac40d5e201993c602515ee2b8 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 29 Apr 2026 18:20:21 +0000 Subject: [PATCH 1/5] fix(nests): use moq-auth.nostrnests.com for JWT mint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous defaults pointed both `service` and `endpoint` URLs at `moq.nostrnests.com:4443`. That host only listens on UDP/QUIC for the WebTransport relay — TCP :4443 is unreachable. OkHttp can't speak HTTP/3, so the JWT-mint POST hangs and fails with `ConnectException`, leaving the room screen stuck on "Reconnecting". The real nostrnests deployment co-locates auth and relay on different hosts (verified against the production NestsUI bundle): relay (WebTransport, QUIC only): https://moq.nostrnests.com:4443 auth (JWT mint, regular HTTPS): https://moq-auth.nostrnests.com Split the defaults accordingly and add a `resolveServerPair` helper that maps a single saved kind-10112 URL onto the (service, endpoint) pair the kind-30312 event needs. The helper recognises both the auth-host and the legacy relay-host the prior defaults wrote, so users upgrading from the broken version migrate transparently the next time they open the create-room sheet. Community deployments that genuinely co-locate keep working via the fallback. Update the recommended-servers list and the kind-10112 documentation to match: the stored `server` URL is the moq-auth base (the URL that ends up in the kind-30312 `service` tag). --- .../nestsServers/NestsServersScreen.kt | 7 +++- .../nestsServers/NestsServersViewModel.kt | 11 ++++-- .../nests/create/CreateNestViewModel.kt | 35 +++++++++++++++++-- .../nestsServers/NestsServersEvent.kt | 18 ++++++---- 4 files changed, 59 insertions(+), 12 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/nestsServers/NestsServersScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/nestsServers/NestsServersScreen.kt index 669f83a19..2ec17fef1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/nestsServers/NestsServersScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/nestsServers/NestsServersScreen.kt @@ -247,8 +247,13 @@ private fun NestsServerEntry( * Built-in suggestion list shown under "Recommended servers". Today * just the public `nostrnests.com` deployment; add new entries here * as community-run moq-rs / moq-auth instances come online. + * + * The stored URL is the moq-auth (service / JWT mint) base — that's + * the URL that ends up in the kind-30312 `service` tag. The matching + * WebTransport relay endpoint is resolved by + * [com.vitorpamplona.amethyst.ui.screen.loggedIn.nests.create.CreateNestViewModel.resolveServerPair]. */ val DEFAULT_NESTS_SERVERS: List = listOf( - NestsServer(name = "nostrnests.com", baseUrl = "https://moq.nostrnests.com:4443"), + NestsServer(name = "nostrnests.com", baseUrl = "https://moq-auth.nostrnests.com"), ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/nestsServers/NestsServersViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/nestsServers/NestsServersViewModel.kt index 52f0d5ad8..b4b37935f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/nestsServers/NestsServersViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/nestsServers/NestsServersViewModel.kt @@ -115,7 +115,11 @@ class NestsServersViewModel : ViewModel() { private fun displayHostName(url: String): String = try { - Rfc3986.host(url).removePrefix("moq.").removePrefix("nests.") + Rfc3986 + .host(url) + .removePrefix("moq-auth.") + .removePrefix("moq.") + .removePrefix("nests.") } catch (_: Throwable) { url } @@ -124,7 +128,10 @@ class NestsServersViewModel : ViewModel() { /** * Display-friendly entry for [NestsServersViewModel]. * - [name] — short host label (e.g. `nostrnests.com`) - * - [baseUrl] — exact bytes that go on the wire (`https://moq.nostrnests.com`) + * - [baseUrl] — exact bytes that go on the wire as the kind-30312 + * `service` tag (`https://moq-auth.nostrnests.com`). The matching + * WebTransport endpoint is resolved at room-create time, not + * stored here. */ data class NestsServer( val name: String, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/create/CreateNestViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/create/CreateNestViewModel.kt index c4c57e46d..647611622 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/create/CreateNestViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/create/CreateNestViewModel.kt @@ -79,7 +79,8 @@ class CreateNestViewModel : ViewModel() { .firstOrNull() ?.takeIf { it.startsWith("http") } if (savedFirst != null) { - _state.update { it.copy(serviceUrl = savedFirst, endpointUrl = savedFirst) } + val (service, endpoint) = resolveServerPair(savedFirst) + _state.update { it.copy(serviceUrl = service, endpointUrl = endpoint) } } } @@ -355,8 +356,38 @@ class CreateNestViewModel : ViewModel() { * Public nostrnests deployment — a blank form produces a working * room here. Users can edit either field to point at their own * moq-auth / moq-relay pair. + * + * The auth sidecar (moq-auth) and the WebTransport relay + * (moq-rs) are on different hosts: moq-auth speaks regular + * HTTPS on :443 to mint JWTs; moq.nostrnests.com:4443 is the + * QUIC/WebTransport endpoint and does NOT accept TCP. Don't + * collapse them back into a single URL — OkHttp can't speak + * HTTP/3, so a TCP POST to :4443 will hang and fail. */ - const val DEFAULT_SERVICE_URL: String = "https://moq.nostrnests.com:4443" + const val DEFAULT_SERVICE_URL: String = "https://moq-auth.nostrnests.com" const val DEFAULT_ENDPOINT_URL: String = "https://moq.nostrnests.com:4443" + + /** + * Map a single saved kind-10112 URL onto the (service, endpoint) + * pair the kind-30312 event needs. Recognises the public + * nostrnests deployment (whether the user has the auth host or + * the relay host saved — earlier app versions stored the relay + * URL by mistake), and falls back to using the URL for both + * tags so community-run deployments that genuinely co-locate + * keep working. + */ + internal fun resolveServerPair(savedUrl: String): Pair = + when (savedUrl.trimEnd('/')) { + "https://moq-auth.nostrnests.com", + "https://moq.nostrnests.com:4443", + "https://moq.nostrnests.com", + -> { + DEFAULT_SERVICE_URL to DEFAULT_ENDPOINT_URL + } + + else -> { + savedUrl to savedUrl + } + } } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/nestsServers/NestsServersEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/nestsServers/NestsServersEvent.kt index b38277b18..d62bbccef 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/nestsServers/NestsServersEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/nestsServers/NestsServersEvent.kt @@ -42,19 +42,23 @@ import com.vitorpamplona.quartz.utils.TimeUtils * "kind": 10112, * "tags": [ * ["alt", "Audio-room (nests) MoQ servers used by the author"], - * ["server", "https://moq.nostrnests.com"], + * ["server", "https://moq-auth.nostrnests.com"], * ["server", "https://moq.example.org"], * ... * ], * "content": "" * } * - * The `server` URL points at the moq-rs / moq-auth deployment's base - * URL — Amethyst's `CreateNestSheet` uses it for both the - * `service` (auth sidecar) and `endpoint` (WebTransport relay) tags - * on the kind-30312 event, since nostrnests's reference deployment - * co-locates them. A future revision can split the two into separate - * tag fields if the community pulls them apart. + * The `server` URL is the moq-auth (JWT mint) base — that's also the + * URL that goes into the kind-30312 `service` tag. The matching + * WebTransport relay endpoint is NOT part of this list; reference + * deployments like nostrnests run the auth sidecar on regular HTTPS + * (e.g. `https://moq-auth.nostrnests.com`) and the QUIC relay on a + * separate host/port (e.g. `https://moq.nostrnests.com:4443`), so + * Amethyst's `CreateNestSheet` resolves the pair via a known-deployment + * mapping (with a fallback that reuses the saved URL for both tags + * when a community deployment genuinely co-locates them). A future + * revision can split the two into separate tag fields if needed. * * This event is shaped 1:1 after `BlossomServersEvent` (kind 10063, * NIP-B7) so existing list-state / settings UI patterns translate From aadb347b84147b3553544d17f00cdbea167d4f28 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 29 Apr 2026 19:32:53 +0000 Subject: [PATCH 2/5] feat(nests): adopt deployed nostrnests schema (streaming/auth/live + paired 10112) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The deployed nostrnests reference (NestsUI v2 + moq-auth + moq-rs) emits a different on-the-wire schema than the previous EGG-01 / EGG-09 drafts and Quartz writers. Verified by reading the production NestsUI bundle. The deployed schema is now canonical: kind:30312 (room event) - relay URL → ["streaming", url] (was ["endpoint", url]) - auth URL → ["auth", url] (was ["service", url]) - live status → ["status", "live"] (was "open") - ended status → ["status", "ended"] (was "closed") - room name → ["title", name] (was ["room", name]) kind:10112 (user MoQ-server list) - 3-element entries: ["server", relay, auth] - first-element name "relay" accepted as a synonym (legacy) - 2-element entries: derive auth host by replacing leading "moq." with "moq-auth.", or prepending "moq-auth." otherwise Implementation - ServiceUrlTag.TAG_NAME = "auth", LEGACY_TAG_NAME = "service" - EndpointUrlTag.TAG_NAME = "streaming", LEGACY_TAG_NAME = "endpoint" - StatusTag enum: PLANNED/LIVE/PRIVATE/ENDED with code "live"/"ended"; legacy "open"/"closed" still parsed on read - NestsServersEvent: emit/read 3-element [server, relay, auth] tags with the legacy-shape tolerances above; expose a NestsServer pair - Account.nestsServers.flow now produces List pairs - NestsServersScreen: rewritten edit-field asks for both URLs, recommended row shows the nostrnests pair, list rows show both URLs - NestsScreen first-time setup writes the nostrnests pair, not a single URL; gate the create FAB on both URLs being parseable - CreateNestViewModel: drop the resolveServerPair hardcoded mapping — the saved pair is now authoritative; defaults stay correct for an empty kind-10112 list Specs - EGG-01 rewritten to canonical streaming/auth/live/ended with a legacy-spelling table; example uses the real nostrnests pair - EGG-02 references "auth" tag throughout; error taxonomy says "ended" - EGG-09 rewritten to 3-element server tag with derivation fallback - New nestsClient/specs/nip-53-proposed.md — a single self-contained proposed update to upstream NIP-53 covering kind 30312 + kind 10112 with the deployed schema and the JWT-mint flow All existing unit tests adjusted; quartz:jvmTest, amethyst:testPlayDebugUnitTest, and nestsClient:jvmTest pass. --- .../vitorpamplona/amethyst/model/Account.kt | 2 +- .../nip53NestsServers/NestsServerListState.kt | 18 +- .../nestsServers/NestsServersScreen.kt | 140 +++++-- .../nestsServers/NestsServersViewModel.kt | 80 ++-- .../amethyst/ui/note/types/MeetingSpace.kt | 6 +- .../DiscoverLiveFeedFilter.kt | 4 +- .../nip53LiveActivities/LiveActivityCard.kt | 4 +- .../loggedIn/home/dal/HomeLiveFilter.kt | 2 +- .../loggedIn/home/live/LiveStatusIndicator.kt | 2 +- .../livestreams/dal/LiveStreamsFeedFilter.kt | 4 +- .../screen/loggedIn/nests/NestsFeedLoaded.kt | 4 +- .../ui/screen/loggedIn/nests/NestsScreen.kt | 18 +- .../nests/create/CreateNestViewModel.kt | 39 +- .../loggedIn/nests/dal/NestsFeedFilter.kt | 6 +- .../nests/room/edit/EditNestViewModel.kt | 6 +- .../loggedIn/nests/room/lobby/NestJoinCard.kt | 4 +- .../participants/RoomParticipantActions.kt | 4 +- .../nests/room/screen/NestFullScreen.kt | 2 +- amethyst/src/main/res/values/strings.xml | 5 + .../nests/room/edit/EditNestViewModelTest.kt | 12 +- nestsClient/specs/EGG-01.md | 82 ++-- nestsClient/specs/EGG-02.md | 28 +- nestsClient/specs/EGG-09.md | 77 ++-- nestsClient/specs/nip-53-proposed.md | 349 ++++++++++++++++++ .../meetingSpaces/MeetingSpaceEvent.kt | 6 +- .../meetingSpaces/tags/EndpointUrlTag.kt | 17 +- .../meetingSpaces/tags/ServiceUrlTag.kt | 16 +- .../meetingSpaces/tags/StatusTag.kt | 38 +- .../nestsServers/NestsServersEvent.kt | 116 ++++-- .../MeetingSpaceEventBuildTest.kt | 8 +- 30 files changed, 855 insertions(+), 244 deletions(-) create mode 100644 nestsClient/specs/nip-53-proposed.md diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt index 1122d328c..981a776fa 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -3087,7 +3087,7 @@ class Account( suspend fun sendBlossomServersList(servers: List) = sendMyPublicAndPrivateOutbox(blossomServers.saveBlossomServersList(servers)) - suspend fun sendNestsServersList(servers: List) = sendMyPublicAndPrivateOutbox(nestsServers.saveNestsServersList(servers)) + suspend fun sendNestsServersList(servers: List) = sendMyPublicAndPrivateOutbox(nestsServers.saveNestsServersList(servers)) suspend fun savePaymentTargets(targets: List) = sendMyPublicAndPrivateOutbox(paymentTargetsState.savePaymentTargets(targets)) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip53NestsServers/NestsServerListState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip53NestsServers/NestsServerListState.kt index 7331ee48c..dd510bdc4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip53NestsServers/NestsServerListState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip53NestsServers/NestsServerListState.kt @@ -25,6 +25,7 @@ import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.NoteState import com.vitorpamplona.amethyst.model.nipB7Blossom.BlossomServerListState import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip53LiveActivities.nestsServers.NestsServer import com.vitorpamplona.quartz.nip53LiveActivities.nestsServers.NestsServersEvent import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -40,15 +41,16 @@ import kotlinx.coroutines.flow.stateIn * [BlossomServerListState] for the nests use case. * * Surfaces: - * - [flow] — current `List` of saved server base URLs + * - [flow] — current `List` of saved (relay, auth) pairs * - [getNestsServersListFlow] — reactive `StateFlow` for * downstream UI to recompose on event arrivals * - [saveNestsServersList] — build + sign a new replaceable kind 10112 * event (preserving prior tags' alt etc.) * - * The list is consumed by `CreateNestViewModel` to default the - * "MoQ service URL" / "MoQ endpoint URL" fields when starting a new - * space, and by the Settings screen for edit / add / remove. + * Each saved entry carries the **two** URLs the kind-30312 event needs: + * the moq-relay WebTransport endpoint and the moq-auth sidecar base. + * They live on different hosts in the deployed nostrnests reference, so + * the pair MUST stay together. */ class NestsServerListState( val signer: NostrSigner, @@ -64,12 +66,12 @@ class NestsServerListState( fun getNestsServersList(): NestsServersEvent? = nestsListNote.event as? NestsServersEvent - fun normalizeServers(note: Note): List { + fun normalizeServers(note: Note): List { val event = note.event as? NestsServersEvent return event?.servers() ?: emptyList() } - val flow: StateFlow> = + val flow: StateFlow> = getNestsServersListFlow() .map { normalizeServers(it.note) @@ -80,7 +82,7 @@ class NestsServerListState( emptyList(), ) - suspend fun saveNestsServersList(servers: List): NestsServersEvent { + suspend fun saveNestsServersList(servers: List): NestsServersEvent { val serverList = getNestsServersList() return if (serverList != null && serverList.tags.isNotEmpty()) { @@ -91,7 +93,7 @@ class NestsServerListState( ) } else { NestsServersEvent.createFromScratch( - relays = servers, + servers = servers, signer = signer, ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/nestsServers/NestsServersScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/nestsServers/NestsServersScreen.kt index 2ec17fef1..10e502f8f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/nestsServers/NestsServersScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/nestsServers/NestsServersScreen.kt @@ -31,15 +31,21 @@ import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.material3.Button import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.text.style.TextAlign @@ -50,19 +56,22 @@ import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.ui.actions.mediaServers.AllMediaServersScreen -import com.vitorpamplona.amethyst.ui.actions.mediaServers.MediaServerEditField import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.topbars.SavingTopBar import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.SettingsCategory import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.SettingsCategoryWithButton import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.amethyst.ui.theme.ButtonBorder import com.vitorpamplona.amethyst.ui.theme.DoubleVertPadding import com.vitorpamplona.amethyst.ui.theme.FeedPadding import com.vitorpamplona.amethyst.ui.theme.SettingsCategoryFirstModifier import com.vitorpamplona.amethyst.ui.theme.SettingsCategorySpacingModifier +import com.vitorpamplona.amethyst.ui.theme.Size10dp import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer import com.vitorpamplona.amethyst.ui.theme.grayText +import com.vitorpamplona.amethyst.ui.theme.placeholderText +import com.vitorpamplona.quartz.nip01Core.tags.references.HttpUrlFormatter import com.vitorpamplona.quartz.nip53LiveActivities.nestsServers.NestsServersEvent /** @@ -70,9 +79,12 @@ import com.vitorpamplona.quartz.nip53LiveActivities.nestsServers.NestsServersEve * MoQ host servers — kind 10112 [NestsServersEvent]. * * Mirrors [AllMediaServersScreen] — top bar with Cancel / Save, list - * of saved servers with delete buttons, edit-field row to add a new - * one, and a "Use defaults" row for one-tap inclusion of - * `nostrnests.com`. + * of saved servers with delete buttons, edit-fields to add a new + * (relay, auth) pair, and a "Use defaults" row for one-tap inclusion + * of `nostrnests.com`. Each server entry is two URLs because the + * deployed nostrnests reference puts the moq-relay (WebTransport) and + * the moq-auth (JWT mint) sidecar on different hosts; collapsing them + * to a single URL breaks JWT minting. * * Reachable from `Settings → Audio-room servers`. */ @@ -161,19 +173,19 @@ private fun NestsServersBody(viewModel: NestsServersViewModel) { ) } } else { - itemsIndexed(servers, key = { _, s -> "saved-${s.baseUrl}" }) { _, entry -> - NestsServerEntry( + itemsIndexed(servers, key = { _, s -> "saved-${s.relay}" }) { _, entry -> + NestsServerRow( server = entry, isAmethystDefault = false, - onAction = { viewModel.removeServer(entry.baseUrl) }, + onAction = { viewModel.removeServer(entry.relay) }, ) } } item { Spacer(modifier = StdVertSpacer) - MediaServerEditField(label = R.string.nests_servers_add_field) { url -> - viewModel.addServer(url) + NestsServerPairEditField { relay, auth -> + viewModel.addServer(relay, auth) } } @@ -184,26 +196,26 @@ private fun NestsServersBody(viewModel: NestsServersViewModel) { modifier = SettingsCategorySpacingModifier, ) { OutlinedButton( - onClick = { viewModel.addServerList(DEFAULT_NESTS_SERVERS.map { it.baseUrl }) }, + onClick = { viewModel.addServerList(DEFAULT_NESTS_SERVERS) }, ) { Text(text = stringRes(id = R.string.nests_servers_use_defaults)) } } } - itemsIndexed(DEFAULT_NESTS_SERVERS, key = { _, s -> "proposed-${s.baseUrl}" }) { _, entry -> - NestsServerEntry( + itemsIndexed(DEFAULT_NESTS_SERVERS, key = { _, s -> "proposed-${s.relay}" }) { _, entry -> + NestsServerRow( server = entry, isAmethystDefault = true, - onAction = { viewModel.addServer(entry.baseUrl) }, + onAction = { viewModel.addServer(entry.relay, entry.auth) }, ) } } } @Composable -private fun NestsServerEntry( - server: NestsServer, +private fun NestsServerRow( + server: NestsServerEntry, isAmethystDefault: Boolean, onAction: () -> Unit, ) { @@ -222,7 +234,12 @@ private fun NestsServerEntry( ) Spacer(modifier = StdVertSpacer) Text( - text = server.baseUrl, + text = "${stringRes(R.string.nests_servers_relay_label)}: ${server.relay}", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.grayText, + ) + Text( + text = "${stringRes(R.string.nests_servers_auth_label)}: ${server.auth}", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.grayText, ) @@ -243,17 +260,96 @@ private fun NestsServerEntry( } } +/** + * Two-field editor for a (relay, auth) pair plus an Add button. The + * Add button is enabled only once both URLs parse — we don't try to + * auto-derive the auth URL on input even though [NestsServersEvent.deriveAuthUrl] + * exists; making the user paste both forces them to think about which + * deployment they're on, which avoids the moq.* / moq-auth.* host + * collision that produced the 4443-TCP connection bug. + */ +@Composable +private fun NestsServerPairEditField(onAdd: (relay: String, auth: String) -> Unit) { + var relay by remember { mutableStateOf("") } + var auth by remember { mutableStateOf("") } + val canSubmit by + remember { + derivedStateOf { + relay.isNotBlank() && + auth.isNotBlank() && + HttpUrlFormatter.isValidUrl(relay) && + HttpUrlFormatter.isValidUrl(auth) + } + } + + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(Size10dp), + ) { + OutlinedTextField( + label = { Text(text = stringRes(R.string.nests_servers_add_relay_field)) }, + modifier = Modifier.fillMaxWidth(), + value = relay, + onValueChange = { relay = it }, + placeholder = { + Text( + text = "https://moq.example.com:4443", + color = MaterialTheme.colorScheme.placeholderText, + maxLines = 1, + ) + }, + singleLine = true, + ) + OutlinedTextField( + label = { Text(text = stringRes(R.string.nests_servers_add_auth_field)) }, + modifier = Modifier.fillMaxWidth(), + value = auth, + onValueChange = { auth = it }, + placeholder = { + Text( + text = "https://moq-auth.example.com", + color = MaterialTheme.colorScheme.placeholderText, + maxLines = 1, + ) + }, + singleLine = true, + ) + Row(horizontalArrangement = Arrangement.End, modifier = Modifier.fillMaxWidth()) { + Button( + onClick = { + if (canSubmit) { + onAdd( + HttpUrlFormatter.normalize(relay), + HttpUrlFormatter.normalize(auth), + ) + relay = "" + auth = "" + } + }, + shape = ButtonBorder, + enabled = canSubmit, + ) { + Text(stringRes(R.string.nests_servers_add_pair_button)) + } + } + } +} + /** * Built-in suggestion list shown under "Recommended servers". Today * just the public `nostrnests.com` deployment; add new entries here * as community-run moq-rs / moq-auth instances come online. * - * The stored URL is the moq-auth (service / JWT mint) base — that's - * the URL that ends up in the kind-30312 `service` tag. The matching - * WebTransport relay endpoint is resolved by - * [com.vitorpamplona.amethyst.ui.screen.loggedIn.nests.create.CreateNestViewModel.resolveServerPair]. + * Each entry carries the relay and auth URLs side-by-side. They are + * stored verbatim into the kind-10112 `["server", relay, auth]` tag + * and pre-fill the kind-30312 `streaming` and `auth` tags when the + * user starts a room. */ -val DEFAULT_NESTS_SERVERS: List = +val DEFAULT_NESTS_SERVERS: List = listOf( - NestsServer(name = "nostrnests.com", baseUrl = "https://moq-auth.nostrnests.com"), + NestsServerEntry( + name = "nostrnests.com", + relay = "https://moq.nostrnests.com:4443", + auth = "https://moq-auth.nostrnests.com", + ), ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/nestsServers/NestsServersViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/nestsServers/NestsServersViewModel.kt index b4b37935f..5630acf17 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/nestsServers/NestsServersViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/nestsServers/NestsServersViewModel.kt @@ -25,6 +25,7 @@ import androidx.lifecycle.ViewModel import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.ui.actions.mediaServers.BlossomServersViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.quartz.nip53LiveActivities.nestsServers.NestsServer import com.vitorpamplona.quartz.nip53LiveActivities.nestsServers.NestsServersEvent import com.vitorpamplona.quartz.utils.Rfc3986 import kotlinx.coroutines.flow.MutableStateFlow @@ -33,10 +34,12 @@ import kotlinx.coroutines.flow.update /** * Edit-buffer for the user's audio-room (NIP-53 / nests) MoQ server - * list. Mirror of [BlossomServersViewModel] scoped down to a plain - * `List` of base URLs since nests servers don't have a - * "selected default" concept (clients pick the first entry when - * starting a new space). + * list. Mirror of [BlossomServersViewModel] but every entry carries + * **two** URLs (the moq-relay WebTransport endpoint and the moq-auth + * sidecar base URL) — those are the two values that end up in the + * kind-30312 `streaming` and `auth` tags. The deployed nostrnests + * reference puts these on different hosts, so we cannot collapse them + * into a single URL. * * The flow: * 1. [init] binds the [AccountViewModel]. @@ -52,7 +55,7 @@ class NestsServersViewModel : ViewModel() { private lateinit var accountViewModel: AccountViewModel private lateinit var account: Account - private val _servers = MutableStateFlow>(emptyList()) + private val _servers = MutableStateFlow>(emptyList()) val servers = _servers.asStateFlow() private var isModified = false @@ -67,31 +70,41 @@ class NestsServersViewModel : ViewModel() { isModified = false val current = account.nestsServers.flow.value _servers.update { - current.map { url -> NestsServer(displayHostName(url), url) } + current.map { it.toEntry() } } } - fun addServer(url: String) { - val normalised = - try { - Rfc3986.normalize(url.trim()) - } catch (_: Throwable) { - url.trim() - } - if (normalised.isBlank()) return - val entry = NestsServer(displayHostName(normalised), normalised) - if (_servers.value.any { it.baseUrl == entry.baseUrl }) return + /** + * Add a server pair. Both URLs go on the wire as the third element + * of the kind-10112 `server` tag (relay first, auth second). The + * recommended-list "Add" button supplies both; the manual "Add a + * server" field also asks for both. + */ + fun addServer( + relay: String, + auth: String, + ) { + val normalisedRelay = normalize(relay) + val normalisedAuth = normalize(auth) + if (normalisedRelay.isBlank() || normalisedAuth.isBlank()) return + val entry = + NestsServerEntry( + name = displayHostName(normalisedRelay), + relay = normalisedRelay, + auth = normalisedAuth, + ) + if (_servers.value.any { it.relay == entry.relay }) return _servers.update { it + entry } isModified = true } - fun addServerList(urls: List) { - urls.forEach { addServer(it) } + fun addServerList(servers: List) { + servers.forEach { addServer(it.relay, it.auth) } } - fun removeServer(baseUrl: String) { + fun removeServer(relay: String) { val before = _servers.value - val after = before.filterNot { it.baseUrl == baseUrl } + val after = before.filterNot { it.relay == relay } if (after.size != before.size) { _servers.update { after } isModified = true @@ -108,11 +121,18 @@ class NestsServersViewModel : ViewModel() { fun save() { if (!isModified) return accountViewModel.launchSigner { - account.sendNestsServersList(_servers.value.map { it.baseUrl }) + account.sendNestsServersList(_servers.value.map { NestsServer(relay = it.relay, auth = it.auth) }) refresh() } } + private fun normalize(url: String): String = + try { + Rfc3986.normalize(url.trim()) + } catch (_: Throwable) { + url.trim() + } + private fun displayHostName(url: String): String = try { Rfc3986 @@ -123,17 +143,23 @@ class NestsServersViewModel : ViewModel() { } catch (_: Throwable) { url } + + private fun NestsServer.toEntry() = + NestsServerEntry( + name = displayHostName(relay), + relay = relay, + auth = auth, + ) } /** * Display-friendly entry for [NestsServersViewModel]. * - [name] — short host label (e.g. `nostrnests.com`) - * - [baseUrl] — exact bytes that go on the wire as the kind-30312 - * `service` tag (`https://moq-auth.nostrnests.com`). The matching - * WebTransport endpoint is resolved at room-create time, not - * stored here. + * - [relay] — moq-relay WebTransport URL (`https://moq.nostrnests.com:4443`) + * - [auth] — moq-auth sidecar base URL (`https://moq-auth.nostrnests.com`) */ -data class NestsServer( +data class NestsServerEntry( val name: String, - val baseUrl: String, + val relay: String, + val auth: String, ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/MeetingSpace.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/MeetingSpace.kt index 978d21d53..9b7f181e9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/MeetingSpace.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/MeetingSpace.kt @@ -125,7 +125,7 @@ fun RenderMeetingSpaceEventInner( CrossfadeIfEnabled(targetState = status, label = "MeetingSpaceStatus", accountViewModel = accountViewModel) { when (it) { - MeetingSpaceStatusTag.STATUS.OPEN -> { + MeetingSpaceStatusTag.STATUS.LIVE -> { MeetingSpaceOpenFlag() } @@ -133,7 +133,7 @@ fun RenderMeetingSpaceEventInner( MeetingSpacePrivateFlag() } - MeetingSpaceStatusTag.STATUS.CLOSED -> { + MeetingSpaceStatusTag.STATUS.ENDED -> { MeetingSpaceClosedFlag() } @@ -170,7 +170,7 @@ fun RenderMeetingSpaceEventInner( modifier = Modifier.fillMaxWidth().padding(top = 8.dp), horizontalArrangement = Arrangement.End, ) { - if (status == MeetingSpaceStatusTag.STATUS.CLOSED) { + if (status == MeetingSpaceStatusTag.STATUS.ENDED) { recording?.let { ListenToRecordingButton(url = it, accountViewModel = accountViewModel) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip53LiveActivities/DiscoverLiveFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip53LiveActivities/DiscoverLiveFeedFilter.kt index 53f212001..6ec04d782 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip53LiveActivities/DiscoverLiveFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip53LiveActivities/DiscoverLiveFeedFilter.kt @@ -159,9 +159,9 @@ open class DiscoverLiveFeedFilter( is MeetingSpaceEvent -> { when (event.status()) { - com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.tags.StatusTag.STATUS.OPEN -> 2 + com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.tags.StatusTag.STATUS.LIVE -> 2 com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.tags.StatusTag.STATUS.PRIVATE -> 1 - com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.tags.StatusTag.STATUS.CLOSED -> 0 + com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.tags.StatusTag.STATUS.ENDED -> 0 else -> 0 } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip53LiveActivities/LiveActivityCard.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip53LiveActivities/LiveActivityCard.kt index 5737494c4..709e3921e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip53LiveActivities/LiveActivityCard.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip53LiveActivities/LiveActivityCard.kt @@ -140,9 +140,9 @@ fun RenderLiveActivityThumb( participants = noteEvent.participants().toImmutableList(), status = when (noteEvent.status()) { - com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.tags.StatusTag.STATUS.OPEN -> StatusTag.STATUS.LIVE + com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.tags.StatusTag.STATUS.LIVE -> StatusTag.STATUS.LIVE com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.tags.StatusTag.STATUS.PRIVATE -> StatusTag.STATUS.PLANNED - com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.tags.StatusTag.STATUS.CLOSED -> StatusTag.STATUS.ENDED + com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.tags.StatusTag.STATUS.ENDED -> StatusTag.STATUS.ENDED else -> null }, starts = null, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/dal/HomeLiveFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/dal/HomeLiveFilter.kt index 85fa20663..b9fece8a2 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/dal/HomeLiveFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/dal/HomeLiveFilter.kt @@ -239,7 +239,7 @@ class HomeLiveFilter( LocalCache.getAddressableNoteIfExists(roomAddress)?.event as? MeetingSpaceEvent ?: return false val status = room.status() - return status == MeetingSpaceStatusTag.STATUS.OPEN || + return status == MeetingSpaceStatusTag.STATUS.LIVE || status == MeetingSpaceStatusTag.STATUS.PRIVATE } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/live/LiveStatusIndicator.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/live/LiveStatusIndicator.kt index 0361bb99c..532a93567 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/live/LiveStatusIndicator.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/live/LiveStatusIndicator.kt @@ -90,7 +90,7 @@ private suspend fun checkChannelIsOnline( // the red dot whenever the room is OPEN/PRIVATE. if (channel.address.kind == MeetingSpaceEvent.KIND) { val room = LocalCache.getAddressableNoteIfExists(channel.address)?.event as? MeetingSpaceEvent - room?.status() == MeetingSpaceStatusTag.STATUS.OPEN || + room?.status() == MeetingSpaceStatusTag.STATUS.LIVE || room?.status() == MeetingSpaceStatusTag.STATUS.PRIVATE } else { // Check if streaming URL is online, fall back to relay check diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/livestreams/dal/LiveStreamsFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/livestreams/dal/LiveStreamsFeedFilter.kt index 334658242..c6c083c54 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/livestreams/dal/LiveStreamsFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/livestreams/dal/LiveStreamsFeedFilter.kt @@ -159,9 +159,9 @@ class LiveStreamsFeedFilter( is MeetingSpaceEvent -> { when (event.status()) { - com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.tags.StatusTag.STATUS.OPEN -> 2 + com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.tags.StatusTag.STATUS.LIVE -> 2 com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.tags.StatusTag.STATUS.PRIVATE -> 1 - com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.tags.StatusTag.STATUS.CLOSED -> 0 + com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.tags.StatusTag.STATUS.ENDED -> 0 else -> 0 } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/NestsFeedLoaded.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/NestsFeedLoaded.kt index 7bab57f0e..72b40ccc5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/NestsFeedLoaded.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/NestsFeedLoaded.kt @@ -339,11 +339,11 @@ fun RenderLiveSpacesThumb( Box(Modifier.padding(10.dp)) { CrossfadeIfEnabled(targetState = card.status, accountViewModel = accountViewModel) { when (it) { - StatusTag.STATUS.OPEN -> { + StatusTag.STATUS.LIVE -> { RenderLiveOrEndedFromPresence(card.id, accountViewModel) } - StatusTag.STATUS.CLOSED -> { + StatusTag.STATUS.ENDED -> { EndedFlag() } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/NestsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/NestsScreen.kt index d7933cf68..d70b3c885 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/NestsScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/NestsScreen.kt @@ -97,7 +97,7 @@ fun NestsScreen( FabBottomBarPadded(nav) { FloatingActionButton( onClick = { - if (nestsServers.any { it.startsWith("http") }) { + if (nestsServers.any { it.relay.startsWith("http") && it.auth.startsWith("http") }) { showCreateSheet = true } else { showSetupDialog = true @@ -137,14 +137,26 @@ fun NestsScreen( if (showSetupDialog) { SetUpAudioServerDialog( - defaultUrl = CreateNestViewModel.DEFAULT_SERVICE_URL, + defaultUrl = CreateNestViewModel.DEFAULT_ENDPOINT_URL, onDismiss = { showSetupDialog = false }, onConfirm = { showSetupDialog = false accountViewModel.launchSigner { try { + // Seed the kind-10112 list with the public nostrnests + // (relay, auth) pair. We can't derive the auth host + // from the relay URL reliably for community-run + // deployments, so the dialog hardcodes the public + // pair; users with their own deployment use the + // Settings → Audio-room servers screen instead. accountViewModel.account.sendNestsServersList( - listOf(CreateNestViewModel.DEFAULT_SERVICE_URL), + listOf( + com.vitorpamplona.quartz.nip53LiveActivities.nestsServers + .NestsServer( + relay = CreateNestViewModel.DEFAULT_ENDPOINT_URL, + auth = CreateNestViewModel.DEFAULT_SERVICE_URL, + ), + ), ) showCreateSheet = true } catch (_: Throwable) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/create/CreateNestViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/create/CreateNestViewModel.kt index 647611622..371b033be 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/create/CreateNestViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/create/CreateNestViewModel.kt @@ -74,13 +74,11 @@ class CreateNestViewModel : ViewModel() { // first-time use flows naturally from the Settings screen. If // the list is empty (or the user hasn't published one yet), keep // the nostrnests.com defaults already in [FormState.defaults]. - val savedFirst = + val first = accountViewModel.account.nestsServers.flow.value .firstOrNull() - ?.takeIf { it.startsWith("http") } - if (savedFirst != null) { - val (service, endpoint) = resolveServerPair(savedFirst) - _state.update { it.copy(serviceUrl = service, endpointUrl = endpoint) } + if (first != null && first.relay.startsWith("http") && first.auth.startsWith("http")) { + _state.update { it.copy(serviceUrl = first.auth, endpointUrl = first.relay) } } } @@ -250,7 +248,7 @@ class CreateNestViewModel : ViewModel() { _state.update { it.copy(isPublishing = true, error = null) } val accountModel = account.account val hostPubkey = accountModel.userProfile().pubkeyHex - val targetStatus = if (current.scheduled) StatusTag.STATUS.PLANNED else StatusTag.STATUS.OPEN + val targetStatus = if (current.scheduled) StatusTag.STATUS.PLANNED else StatusTag.STATUS.LIVE val template = MeetingSpaceEvent.build( room = current.roomName.trim(), @@ -358,36 +356,15 @@ class CreateNestViewModel : ViewModel() { * moq-auth / moq-relay pair. * * The auth sidecar (moq-auth) and the WebTransport relay - * (moq-rs) are on different hosts: moq-auth speaks regular + * (moq-rs) live on different hosts: moq-auth speaks regular * HTTPS on :443 to mint JWTs; moq.nostrnests.com:4443 is the * QUIC/WebTransport endpoint and does NOT accept TCP. Don't * collapse them back into a single URL — OkHttp can't speak - * HTTP/3, so a TCP POST to :4443 will hang and fail. + * HTTP/3, so a TCP POST to :4443 will hang and fail. Both + * defaults are emitted on the kind-30312 event as the deployed + * `streaming` (relay) and `auth` (sidecar) tags. */ const val DEFAULT_SERVICE_URL: String = "https://moq-auth.nostrnests.com" const val DEFAULT_ENDPOINT_URL: String = "https://moq.nostrnests.com:4443" - - /** - * Map a single saved kind-10112 URL onto the (service, endpoint) - * pair the kind-30312 event needs. Recognises the public - * nostrnests deployment (whether the user has the auth host or - * the relay host saved — earlier app versions stored the relay - * URL by mistake), and falls back to using the URL for both - * tags so community-run deployments that genuinely co-locate - * keep working. - */ - internal fun resolveServerPair(savedUrl: String): Pair = - when (savedUrl.trimEnd('/')) { - "https://moq-auth.nostrnests.com", - "https://moq.nostrnests.com:4443", - "https://moq.nostrnests.com", - -> { - DEFAULT_SERVICE_URL to DEFAULT_ENDPOINT_URL - } - - else -> { - savedUrl to savedUrl - } - } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/dal/NestsFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/dal/NestsFeedFilter.kt index fff92ef7e..b9f071612 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/dal/NestsFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/dal/NestsFeedFilter.kt @@ -168,7 +168,7 @@ class NestsFeedFilter( presenceCutoff: Long, ): Boolean { val status = event.status() - if (status != StatusTag.STATUS.OPEN && status != StatusTag.STATUS.PRIVATE) return true + if (status != StatusTag.STATUS.LIVE && status != StatusTag.STATUS.PRIVATE) return true if (event.createdAt > presenceCutoff) return true val channel = LocalCache.getLiveActivityChannelIfExists(event.address()) ?: return false @@ -231,9 +231,9 @@ class NestsFeedFilter( private fun convertStatusToOrder(event: MeetingSpaceEvent?): Int = when (event?.status()) { - StatusTag.STATUS.OPEN -> 2 + StatusTag.STATUS.LIVE -> 2 StatusTag.STATUS.PRIVATE -> 1 - StatusTag.STATUS.CLOSED -> 0 + StatusTag.STATUS.ENDED -> 0 else -> 0 } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/edit/EditNestViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/edit/EditNestViewModel.kt index 78a45dc31..faedda391 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/edit/EditNestViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/edit/EditNestViewModel.kt @@ -42,7 +42,7 @@ import kotlinx.coroutines.flow.update * tag so the relay treats it as a replacement of the original. * * Closing the room ([closeRoom]) is the same path with - * [StatusTag.STATUS.CLOSED] forced regardless of the form fields, + * [StatusTag.STATUS.ENDED] forced regardless of the form fields, * so a host can close without editing anything else. * * Participants are preserved verbatim — re-publishing must NOT lose @@ -92,10 +92,10 @@ class EditNestViewModel : ViewModel() { fun setServiceUrl(value: String) = _state.update { it.copy(serviceUrl = value) } /** Publish the edited room. Returns `true` on success. */ - suspend fun save(): Boolean = republish(StatusTag.STATUS.OPEN) + suspend fun save(): Boolean = republish(StatusTag.STATUS.LIVE) /** Close the room (host only). Republishes with status=CLOSED. */ - suspend fun closeRoom(): Boolean = republish(StatusTag.STATUS.CLOSED) + suspend fun closeRoom(): Boolean = republish(StatusTag.STATUS.ENDED) private suspend fun republish(targetStatus: StatusTag.STATUS): Boolean { val avm = account ?: return false diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/lobby/NestJoinCard.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/lobby/NestJoinCard.kt index c5a8ea661..b9ed966c2 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/lobby/NestJoinCard.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/lobby/NestJoinCard.kt @@ -163,7 +163,7 @@ private fun NestJoinCardContent( } Spacer(StdHorzSpacer) when (status) { - StatusTag.STATUS.OPEN -> { + StatusTag.STATUS.LIVE -> { MeetingSpaceOpenFlag() } @@ -171,7 +171,7 @@ private fun NestJoinCardContent( MeetingSpacePrivateFlag() } - StatusTag.STATUS.CLOSED -> { + StatusTag.STATUS.ENDED -> { MeetingSpaceClosedFlag() } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/participants/RoomParticipantActions.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/participants/RoomParticipantActions.kt index 964a0f5c8..88311e720 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/participants/RoomParticipantActions.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/participants/RoomParticipantActions.kt @@ -77,7 +77,7 @@ internal object RoomParticipantActions { } else { all + ParticipantTag(targetPubkey, null, newRole.code, null) } - return rebuild(original, mutated, original.status() ?: StatusTag.STATUS.OPEN) + return rebuild(original, mutated, original.status() ?: StatusTag.STATUS.LIVE) } /** @@ -109,7 +109,7 @@ internal object RoomParticipantActions { val target = all.firstOrNull { it.pubKey == targetPubkey } ?: return null if (target.role.equals(ROLE.HOST.code, ignoreCase = true)) return null val remaining = all.filterNot { it.pubKey == targetPubkey } - return rebuild(original, remaining, original.status() ?: StatusTag.STATUS.OPEN) + return rebuild(original, remaining, original.status() ?: StatusTag.STATUS.LIVE) } private fun rebuild( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/screen/NestFullScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/screen/NestFullScreen.kt index b6f158c5d..15a97ae77 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/screen/NestFullScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/screen/NestFullScreen.kt @@ -552,7 +552,7 @@ private suspend fun closeMeetingSpace( ) return try { val template = - EditNestViewModel.buildEditTemplate(event, verbatim, StatusTag.STATUS.CLOSED) + EditNestViewModel.buildEditTemplate(event, verbatim, StatusTag.STATUS.ENDED) accountViewModel.account.signAndComputeBroadcast(template) true } catch (_: Throwable) { diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 83f6351c3..0567dec43 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -610,6 +610,11 @@ Your servers Saved as a kind-10112 replaceable event so other clients can read your preference. Add a nest server + Relay (WebTransport) URL + Auth (JWT mint) URL + Add + Relay + Auth Recommended servers Built-in suggestions you can add to your list with one tap. Use Amethyst defaults diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/edit/EditNestViewModelTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/edit/EditNestViewModelTest.kt index e8dc731f1..46db127bc 100644 --- a/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/edit/EditNestViewModelTest.kt +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/edit/EditNestViewModelTest.kt @@ -44,7 +44,7 @@ class EditNestViewModelTest { buildList> { add(arrayOf("d", dTag)) add(arrayOf("room", roomName)) - add(arrayOf("status", StatusTag.STATUS.OPEN.code)) + add(arrayOf("status", StatusTag.STATUS.LIVE.code)) add(arrayOf("service", service)) add(arrayOf("endpoint", endpoint)) add(arrayOf("summary", summary)) @@ -85,7 +85,7 @@ class EditNestViewModelTest { val newForm = form(dTag = "rt-42", roomName = "New name", summary = "New summary") val template = - EditNestViewModel.buildEditTemplate(src, newForm, StatusTag.STATUS.OPEN) + EditNestViewModel.buildEditTemplate(src, newForm, StatusTag.STATUS.LIVE) // Same d-tag → same address → relay treats as replacement. val dTag = template.tags.firstOrNull { it.firstOrNull() == "d" }?.getOrNull(1) @@ -113,7 +113,7 @@ class EditNestViewModelTest { val newForm = form(dTag = "rt-1", roomName = "Renamed") val template = - EditNestViewModel.buildEditTemplate(src, newForm, StatusTag.STATUS.OPEN) + EditNestViewModel.buildEditTemplate(src, newForm, StatusTag.STATUS.LIVE) val pTagPubkeys = template.tags.filter { it.firstOrNull() == "p" }.map { it[1] } // Host plus two speakers — none can be lost on a rename. @@ -129,10 +129,10 @@ class EditNestViewModelTest { val sameForm = form(dTag = "rt-99", roomName = src.room().orEmpty()) val template = - EditNestViewModel.buildEditTemplate(src, sameForm, StatusTag.STATUS.CLOSED) + EditNestViewModel.buildEditTemplate(src, sameForm, StatusTag.STATUS.ENDED) val statusTag = template.tags.firstOrNull { it.firstOrNull() == "status" }?.getOrNull(1) - assertEquals(StatusTag.STATUS.CLOSED.code, statusTag) + assertEquals(StatusTag.STATUS.ENDED.code, statusTag) val dTag = template.tags.firstOrNull { it.firstOrNull() == "d" }?.getOrNull(1) assertEquals("rt-99", dTag) } @@ -143,7 +143,7 @@ class EditNestViewModelTest { val emptied = form(dTag = "rt-1", roomName = "X", summary = " ", imageUrl = "") val template = - EditNestViewModel.buildEditTemplate(src, emptied, StatusTag.STATUS.OPEN) + EditNestViewModel.buildEditTemplate(src, emptied, StatusTag.STATUS.LIVE) // Blank inputs MUST NOT carry over from the original; otherwise // a "delete the summary" edit would silently fail. diff --git a/nestsClient/specs/EGG-01.md b/nestsClient/specs/EGG-01.md index 5d46db9e2..0ec091eb4 100644 --- a/nestsClient/specs/EGG-01.md +++ b/nestsClient/specs/EGG-01.md @@ -20,12 +20,12 @@ Every other EGG layers on top of this event. "pubkey": "", "tags": [ ["d", ""], - ["room", ""], + ["title", ""], ["summary", ""], ["image", ""], - ["status", "open" | "private" | "closed" | "planned"], - ["service", ""], - ["endpoint", ""], + ["status", "live" | "private" | "ended" | "planned"], + ["streaming", ""], + ["auth", ""], ["relays", "", "", ...], // ONE tag, multiple values ["p", "", "", "host" | "admin" | "speaker"], ... @@ -43,34 +43,60 @@ and whose remaining elements are wss URLs. Implementers MUST NOT emit one malformed event and MAY repair it by concatenating the values, but publishers that emit it are non-conformant. +### Tag-name back-compat + +Earlier drafts of this EGG used `room` for the room name, `service` for the +auth URL, `endpoint` for the relay URL, and `open` / `closed` for the live / +ended states. Those names are still emitted by some legacy clients. Receivers +MUST accept all of: + +| Canonical (this EGG) | Legacy (older drafts) | +|----------------------|------------------------------| +| `title` | `room` | +| `streaming` | `endpoint` | +| `auth` | `service` | +| `status: live` | `status: open` | +| `status: ended` | `status: closed` | + +When a single event carries both spellings (e.g. `streaming` AND `endpoint`), +the canonical name wins; the legacy name is treated as a duplicate. Publishers +MUST emit only the canonical names. + ## Behavior 1. Hosts MUST emit exactly one `kind:30312` event per room. Updating the room (rename, status change, role grants) MUST re-publish the same `d` tag with a higher `created_at`. -2. The `room`, `status`, `service`, and `endpoint` tags MUST be present. A +2. The `title`, `status`, `streaming`, and `auth` tags MUST be present. A client receiving an event without all four MUST treat the room as un-joinable. -3. The `service` value MUST be the base URL of an EGG-02 auth sidecar (do +3. The `auth` value MUST be the base URL of an EGG-02 auth sidecar (do not include the `/auth` suffix). Receivers MUST normalize the value by - stripping a single trailing `/` before constructing sub-paths. -4. The `endpoint` value MUST be the base URL of a WebTransport-capable - moq-relay implementing EGG-03. + stripping a single trailing `/` before constructing sub-paths. The auth + sidecar speaks HTTP/1.1 or HTTP/2 over TCP/TLS — it MUST be reachable on + a port that accepts TCP (typically 443). +4. The `streaming` value MUST be the base URL of a WebTransport-capable + moq-relay implementing EGG-03. Because WebTransport runs over QUIC, the + `streaming` host's port may listen on UDP only — clients MUST NOT use + it for the JWT-mint POST. The two URLs are separate fields precisely so + they can live on different hosts/ports (the public nostrnests deployment + does: `streaming = https://moq.nostrnests.com:4443`, + `auth = https://moq-auth.nostrnests.com`). 5. The `p` tag MUST list the host as the first participant. Additional `p` tags grant roles (EGG-07). 6. Status semantics: - - `open` — room is live, anyone can join. The auth sidecar MUST mint a - listener token to any well-formed NIP-98 request. + - `live` — room is in progress, anyone can join. The auth sidecar MUST + mint a listener token to any well-formed NIP-98 request. - `private` — room is live, but the auth sidecar applies an out-of-band allowlist. The allowlist mechanism is implementation-defined; this spec only mandates that a non-allowlisted requester receives `403` (see EGG-02 error taxonomy). Clients without a path to acquire access MUST render `private` rooms as un-joinable rather than attempt to connect blind. - - `closed` — room is over, audio plane SHOULD be torn down server-side. + - `ended` — room is over, audio plane SHOULD be torn down server-side. - `planned` — room hasn't started; see EGG-08. -7. A host MAY treat a room as auto-closed after 8 h of `created_at` staleness - even if the published status is still `open`/`private`. Receivers SHOULD do +7. A host MAY treat a room as auto-ended after 8 h of `created_at` staleness + even if the published status is still `live`/`private`. Receivers SHOULD do the same to avoid stale rooms hanging at the top of the live UI. 8. An empty `content` field is REQUIRED. Future EGGs MAY define structured content; until then, peers MUST ignore non-empty content rather than @@ -101,15 +127,17 @@ publishers that emit it are non-conformant. Auth sidecars that cannot find the room MUST return HTTP 410 `unknown_room` (EGG-02 error taxonomy); peers SHOULD retry with 1 s / 2 s / 4 s exponential backoff before surfacing the error. -13. **Service / endpoint selection (host-side guidance).** When - composing a new room, hosts SHOULD pre-fill `service` and - `endpoint` from the FIRST entry of their own `kind:10112` user - server list (EGG-09). When the host has no `kind:10112`, the - client MAY ship a built-in default URL but MUST allow user - override. Both `service` and `endpoint` MUST be `https://` URLs; - `http://` MUST be rejected at compose time (mirrors EGG-09 - rule 1). The `service` and `endpoint` MAY be the same base URL - (a single deployment serving both is the common case). +13. **Streaming / auth selection (host-side guidance).** When + composing a new room, hosts SHOULD pre-fill `streaming` and + `auth` from the FIRST entry of their own `kind:10112` user + server list (EGG-09 — each entry already carries the pair). + When the host has no `kind:10112`, the client MAY ship a + built-in default pair but MUST allow user override. Both + `streaming` and `auth` MUST be `https://` URLs; `http://` MUST + be rejected at compose time (mirrors EGG-09 rule 1). The two + URLs MAY point at the same host:port for community deployments + that genuinely co-locate both services, but they are independent + fields and clients MUST NOT assume they share an authority. ## Example @@ -120,11 +148,11 @@ publishers that emit it are non-conformant. "created_at": 1714003200, "tags": [ ["d", "office-hours-2026-04"], - ["room", "Office Hours"], + ["title", "Office Hours"], ["summary", "Weekly Q&A"], - ["status", "open"], - ["service", "https://moq.nostrnests.com"], - ["endpoint", "https://moq.nostrnests.com"], + ["status", "live"], + ["streaming", "https://moq.nostrnests.com:4443"], + ["auth", "https://moq-auth.nostrnests.com"], ["p", "abc...host", "wss://relay.example", "host"], ["p", "def...co", "wss://relay.example", "speaker"] ], diff --git a/nestsClient/specs/EGG-02.md b/nestsClient/specs/EGG-02.md index b8e5632ac..651f828a1 100644 --- a/nestsClient/specs/EGG-02.md +++ b/nestsClient/specs/EGG-02.md @@ -11,15 +11,17 @@ auth sidecar (moq-auth) using a NIP-98 HTTP signature, receives a short-lived JWT, then opens a WebTransport session against the moq-relay carrying the JWT in the URL. -Two separate URLs are involved: the `service` tag from EGG-01 is the auth -sidecar; the `endpoint` tag is the relay. +Two separate URLs are involved: the `auth` tag from EGG-01 is the auth +sidecar (HTTP/1.1 or HTTP/2 over TCP); the `streaming` tag is the moq-relay +(WebTransport over QUIC). The deployed nostrnests reference puts these on +different hosts — see EGG-01 §4 for why they cannot be collapsed. ## Wire format ### Step 1 — token request ``` -POST /auth +POST /auth Authorization: Nostr Content-Type: application/json; charset=utf-8 @@ -29,8 +31,10 @@ Content-Type: application/json; charset=utf-8 `` is `30312` for nests audio rooms (EGG-01). -The `` URL is the EGG-01 `service` tag value with any trailing `/` -stripped, then `/auth` appended literally. +The `` URL is the EGG-01 `auth` tag value with any trailing `/` +stripped, then `/auth` appended literally. (The same path component is +used regardless of how the host is named — `` here is just the +sidecar base URL, so the full request line is `POST /auth`.) The body is a single-line UTF-8 JSON object — the server hashes the **exact bytes sent** to compare against NIP-98's `payload` tag, so producers MUST @@ -50,7 +54,7 @@ The signed kind 27235 event MUST carry exactly these tags (NIP-98 §1): "pubkey": "", "created_at": , "tags": [ - ["u", "/auth"], // exact request URL, scheme included + ["u", "/auth"], // exact request URL, scheme included ["method", "POST"], ["payload", ""] ], @@ -91,7 +95,7 @@ RFC 7518 §3.4). The auth sidecar MUST publish its public verification keys as a JWKS at: ``` -GET /.well-known/jwks.json +GET /.well-known/jwks.json ``` The response is an `application/json` body shaped per RFC 7517: @@ -108,7 +112,7 @@ The response is an `application/json` body shaped per RFC 7517: } ``` -The relay (EGG-03 endpoint) MUST verify inbound JWTs against this JWKS. +The relay (EGG-03 streaming endpoint) MUST verify inbound JWTs against this JWKS. Relays SHOULD cache the JWKS for at most 5 minutes so a key rotation propagates without requiring a relay restart. A relay that cannot reach the JWKS endpoint MUST refuse new sessions rather than fall through to @@ -120,7 +124,7 @@ the JWKS endpoint MUST refuse new sessions rather than fall through to :method = CONNECT :protocol = webtransport :scheme = https -:authority = +:authority = :path = /?jwt= ``` @@ -167,7 +171,7 @@ MAY ignore `reason` but MUST surface `error` to user-facing error toasts. | 401 | `wrong_method` | NIP-98 `method` tag is not `POST` | | 401 | `wrong_payload` | NIP-98 `payload` tag does not match sha256 of the body bytes | | 401 | `stale` | NIP-98 `created_at` outside the ±60 s tolerance | -| 403 | `room_closed` | room status is `closed` (EGG-01) or `planned` (EGG-08) | +| 403 | `room_closed` | room status is `ended` (EGG-01) or `planned` (EGG-08) | | 403 | `not_invited` | room status is `private` and requester is not on the allowlist | | 403 | `publish_forbidden` | `publish: true` requested but caller is not a speaker per EGG-07 | | 410 | `unknown_room` | no `kind:30312` known to the sidecar for `(host, d)` | @@ -177,7 +181,7 @@ MAY ignore `reason` but MUST surface `error` to user-facing error toasts. Receivers MUST treat unknown 4xx slugs as "fatal, do not retry" and unknown 5xx slugs as "transient, retry with exponential backoff". -The relay (EGG-03 endpoint) signals authorization failures through +The relay (EGG-03 streaming endpoint) signals authorization failures through WebTransport CONNECT response codes, NOT through the auth-sidecar table: | WT status | meaning | @@ -190,7 +194,7 @@ WebTransport CONNECT response codes, NOT through the auth-sidecar table: ## Example ``` -> POST https://moq.nostrnests.com/auth +> POST https://moq-auth.nostrnests.com/auth > Authorization: Nostr eyJ...kind27235... > Content-Type: application/json > diff --git a/nestsClient/specs/EGG-09.md b/nestsClient/specs/EGG-09.md index 8bdb5f478..de7542271 100644 --- a/nestsClient/specs/EGG-09.md +++ b/nestsClient/specs/EGG-09.md @@ -1,29 +1,33 @@ # EGG-09: User server list (`kind:10112`) `status: draft` -`requires: NIP-01` +`requires: NIP-01, EGG-01` `category: optional` ## Summary A user MAY publish a list of nests servers they prefer to host on. Clients -read this list to default-fill the "service" and "endpoint" fields when the -user opens a new room, and to suggest peer rooms in discovery surfaces. +read this list to default-fill the `streaming` (relay) and `auth` (sidecar) +fields of a new `kind:30312` room (EGG-01), and to suggest peer rooms in +discovery surfaces. This is the audio-rooms equivalent of the user-server-list patterns in -Blossom (BUD-03) and Mostr. +Blossom (BUD-03) and Mostr — but every entry carries **two** URLs because +the moq-relay and moq-auth sidecar live on different hosts in the +deployed reference (see EGG-01 §4). ## Wire format -`kind:10112` is a NIP-01 *replaceable* event with one entry per server: +`kind:10112` is a NIP-01 *replaceable* event. Each `["server", relay, auth]` +tag carries one deployment: ```json { "kind": 10112, "pubkey": "", "tags": [ - ["server", ""], - ["server", ""], + ["server", "", ""], + ["server", "", ""], ... ], "content": "", @@ -31,29 +35,58 @@ Blossom (BUD-03) and Mostr. } ``` -Each `["server", url]` is one entry. The relative ordering MUST be -preserved by receivers — earlier entries are higher priority. +The first element of the tag is the literal string `"server"`. The second +is the moq-relay (WebTransport) URL — what the kind-30312 `streaming` tag +will end up with. The third is the moq-auth (JWT mint) URL — what the +kind-30312 `auth` tag will end up with. + +The relative ordering MUST be preserved by receivers — earlier entries are +higher priority. + +### Back-compat shapes + +Earlier deployed clients used two looser shapes; receivers MUST tolerate +both, publishers MUST emit only the canonical 3-element form above: + +1. **Legacy first-element name `relay`.** The earliest NestsUI iteration + wrote `["relay", relay, auth]`. Receivers MUST accept this name as a + synonym for `server`. +2. **Auth-URL omitted.** A 2-element `["server", relay]` (or + `["relay", relay]`) entry has no auth URL on the wire. Receivers MUST + derive the auth URL from the relay URL using the following rule, which + matches the deployed nostrnests fallback: + - Replace a leading `moq.` host label with `moq-auth.` (preserving the + scheme and dropping any explicit port). + Example: `https://moq.example.com:4443` → `https://moq-auth.example.com`. + - If the relay host has no `moq.` prefix, prepend `moq-auth.`. + Example: `https://relay.example.com:4443` → `https://moq-auth.relay.example.com`. + - If the relay URL is unparseable, drop the entry. + + Receivers SHOULD NOT cache the legacy 2-element form — when re-publishing + the user's list (EGG-09 rule 4), they MUST emit the 3-element form with + the derived auth URL filled in. ## Behavior -1. Each `server` value MUST be a fully-qualified URL beginning with - `https://`. Receivers MUST reject (drop) entries that are not - well-formed HTTPS URLs. -2. Receivers MUST de-duplicate entries by exact-string match after - trimming a single trailing `/`. Order of remaining entries MUST be - preserved (the FIRST occurrence wins). +1. Each `relay` and each `auth` value MUST be a fully-qualified URL beginning + with `https://`. Receivers MUST drop entries that are not well-formed + HTTPS URLs. +2. Receivers MUST de-duplicate entries by exact-string match on the relay + URL (after trimming a single trailing `/`). Order of remaining entries + MUST be preserved (the FIRST occurrence wins). 3. When the user opens the create-room sheet, the client SHOULD pre-fill - the EGG-01 `service` and `endpoint` fields from the FIRST entry in - the list (a single nests deployment serves both via the same base - URL today). + the EGG-01 `streaming` and `auth` fields from the FIRST entry in the + list. The `streaming` field gets the entry's relay URL; the `auth` + field gets the entry's auth URL. Clients MUST NOT collapse the two + into a single field. 4. Users MAY enumerate up to 64 servers. Receivers MUST tolerate longer lists by truncating to the first 64. 5. Hosts who list a server in their `kind:10112` are not declaring an alliance — they are merely advertising "I will probably create rooms here". Receivers MUST NOT use the list as a moderation signal. 6. The list is purely a defaults / discovery hint. A `kind:30312` event's - own `service` / `endpoint` tags are authoritative for that specific - room and override the user list at join time. + own `streaming` / `auth` tags are authoritative for that specific room + and override the user list at join time. ## Example @@ -63,8 +96,8 @@ preserved by receivers — earlier entries are higher priority. "pubkey": "abc...host", "created_at": 1714003000, "tags": [ - ["server", "https://moq.nostrnests.com"], - ["server", "https://moq.example.org"] + ["server", "https://moq.nostrnests.com:4443", "https://moq-auth.nostrnests.com"], + ["server", "https://relay.example.org:4443", "https://moq-auth.example.org"] ], "content": "", "id": "...", diff --git a/nestsClient/specs/nip-53-proposed.md b/nestsClient/specs/nip-53-proposed.md new file mode 100644 index 000000000..e3ab6ba47 --- /dev/null +++ b/nestsClient/specs/nip-53-proposed.md @@ -0,0 +1,349 @@ +# NIP-53 + +## Live Activities + +`draft` `optional` + +Service providers want to offer live activities to the Nostr network in such a way that participants can easily logon, chat, send zaps and follow other participants. This NIP describes a framework to advertise and discover hosts of live activities, such as streaming and audio rooms. + +## Format + +The format uses two NIP-01 *addressable* events, plus one *replaceable* event for user preferences: + +| kind | name | scope | +|-------|---------------------------------|----------------------| +| 30311 | Live Event | streaming | +| 30312 | Interactive Room (Audio Space) | nests / audio rooms | +| 10112 | User MoQ-Server List | nests user prefs | + +This document is a **proposed update to NIP-53** based on what the +deployed nostrnests reference (NestsUI v2 + moq-auth + moq-rs) +actually emits. Earlier versions of this NIP described kind 30312 +with the `service`, `endpoint`, and `status: open|closed` tags; the +deployed reference uses `auth`, `streaming`, and `status: live|ended` +instead. Receivers MUST tolerate both tag-name spellings (canonical +vs. legacy) but publishers MUST emit the canonical names defined +below. + +### Live Event (kind 30311) + +Unchanged from the current NIP-53. Streaming hosts publish a +NIP-01 addressable event with the following tags: + +```json +{ + "kind": 30311, + "tags": [ + ["d", ""], + ["title", ""], + ["summary", ""], + ["image", ""], + ["t", "hashtag"], + ["streaming", ""], + ["recording", ""], + ["starts", ""], + ["ends", ""], + ["status", ""], + ["current_participants", ""], + ["total_participants", ""], + ["p", "", "", "", ""], + ["relays", "", "", ...] + ] +} +``` + +### Interactive Room (kind 30312) + +A `kind:30312` is the audio-room (a.k.a. **nest**) counterpart of a +streaming Live Event. The event's pubkey is the host. Subscribers read +this event to learn who runs the room, where the audio plane lives, +who else is invited, and whether the room is currently live. + +```json +{ + "kind": 30312, + "pubkey": "", + "tags": [ + ["d", ""], + ["title", ""], + ["summary", ""], + ["image", ""], + ["status", "live" | "private" | "ended" | "planned"], + ["streaming", ""], + ["auth", ""], + ["starts", ""], + ["color", ""], + ["relays", "", "", ...], + ["p", "", "", "host" | "admin" | "speaker"], + ... + ], + "content": "", + ... +} +``` + +Required tags: `d`, `title`, `status`, `streaming`, `auth`, plus a +single `p` entry naming the host. + +#### `streaming` tag + +Base URL of a WebTransport-capable moq-relay (the audio plane). The +relay listens on QUIC; the URL's port may accept UDP only. Clients +MUST NOT issue HTTP requests against this URL — it is the +WebTransport authority for the `:authority` pseudo-header during +session establishment. + +#### `auth` tag + +Base URL of the moq-auth sidecar (the JWT mint). The sidecar speaks +HTTP/1.1 or HTTP/2 over TCP/TLS — it MUST be reachable on a port +that accepts TCP (typically 443). Clients MUST NOT collapse this URL +into the `streaming` URL: the public reference deployment hosts them +on different hosts (`moq.nostrnests.com:4443` for the relay, +`moq-auth.nostrnests.com` for auth), and an HTTP request against the +QUIC-only relay port hangs without ever reaching a server. + +The two URLs MAY point at the same authority for community +deployments that genuinely co-locate both services, but they remain +independent fields and clients MUST NOT assume they share an +authority. + +#### `status` tag + +| value | meaning | +|-----------|--------------------------------------------------------------------| +| `live` | Room in progress, anyone may join. Auth sidecar mints listener tokens. | +| `private` | Room in progress with an out-of-band allowlist. Auth sidecar replies `403` to non-allowlisted requesters. | +| `ended` | Room is over; audio plane SHOULD be torn down server-side. | +| `planned` | Room is scheduled to start at the `starts` unix-second timestamp. | + +Receivers MUST also accept `open` (synonym for `live`) and `closed` +(synonym for `ended`) on read for back-compat with earlier drafts. +Publishers MUST emit only the canonical names. + +#### `p` tag (participant) + +`["p", , , , ]` + +`role` is one of `host`, `admin`, `speaker`. Role flips re-publish +the kind-30312 event. `proof` is reserved for invitee +acknowledgements; absent today. + +#### `relays` tag + +A SINGLE tag whose first element is the literal string `"relays"` +and whose remaining elements are wss URLs. Implementers MUST NOT +emit one `["relays", url]` tag per relay. + +#### Authentication & WebTransport handshake + +To open the audio plane, a peer mints a JWT against the auth sidecar +using NIP-98 and then opens a WebTransport CONNECT against the relay +carrying the JWT in the URL. + +##### Step 1 — token request + +``` +POST /auth +Authorization: Nostr +Content-Type: application/json; charset=utf-8 + +{ "namespace": "nests/30312::", + "publish": } +``` + +`` is the kind-30312 `auth` tag value with any trailing `/` +stripped. The body is a single-line UTF-8 JSON object — the server +hashes the **exact bytes sent** to compare against NIP-98's `payload` +tag, so producers MUST NOT pretty-print or re-order keys after +signing. The NIP-98 event's `u` tag MUST be the full URL +`/auth` (scheme included). + +`publish: true` requests a speaker token; `publish: false` a listener +token. The auth sidecar MUST gate `publish: true` on the requester +appearing in the kind-30312 event's `p` tag with role `host`, +`admin`, or `speaker`. + +##### Step 2 — token response + +``` +HTTP/1.1 200 OK +Content-Type: application/json + +{ "token": "" } +``` + +The JWT is signed `alg: ES256`. The sidecar MUST publish its public +keys at `GET /.well-known/jwks.json` (RFC 7517). The relay +MUST verify inbound JWTs against this JWKS and SHOULD cache it for +at most 5 minutes. + +JWT claims (minimum): + +| claim | meaning | +|---------|--------------------------------------------------------------------| +| `root` | The `namespace` echoed back. Authorization is scoped here. | +| `get` | Read-allowed sub-paths; for nests this is `[""]` (any). | +| `put` | Publish-allowed sub-paths. Listener: `[]`. Speaker: `[]`. | +| `iat` | Unix seconds; issuance. | +| `exp` | Unix seconds; expiry. Recommended `iat + 600`. | + +There is no refresh endpoint. Long sessions MUST mint a fresh token +and open a new WebTransport session before the old token expires. + +##### Step 3 — WebTransport CONNECT + +``` +:method = CONNECT +:protocol = webtransport +:scheme = https +:authority = +:path = /?jwt= +``` + +The relay reads the JWT from the `?jwt=` query parameter and MUST +NOT trust the WebTransport authority for authorization — only the +JWT's `root` claim. A token issued for namespace A MUST NOT be +accepted on a session opened against namespace B. + +Peers MUST NOT log the JWT or include it in error reports. + +##### Error taxonomy + +The auth sidecar MUST return `application/json` bodies of shape +`{ "error": "", "reason": "" }` on non-2xx +responses. Defined slugs: + +| status | slug | when | +|--------|---------------------|------------------------------------------------------------------| +| 400 | `bad_request` | malformed JSON body or missing `namespace` | +| 400 | `bad_namespace` | namespace does not match `nests/::` | +| 401 | `bad_nip98` | Authorization header missing, malformed, or `id`/`sig` invalid | +| 401 | `wrong_url` | NIP-98 `u` tag does not match the actual request URL | +| 401 | `wrong_method` | NIP-98 `method` tag is not `POST` | +| 401 | `wrong_payload` | NIP-98 `payload` tag does not match sha256 of the body bytes | +| 401 | `stale` | NIP-98 `created_at` outside the ±60 s tolerance | +| 403 | `room_closed` | room status is `ended` or `planned` | +| 403 | `not_invited` | room status is `private` and requester not on allowlist | +| 403 | `publish_forbidden` | `publish: true` requested but caller is not a speaker | +| 410 | `unknown_room` | no `kind:30312` known to the sidecar for `(host, d)` | +| 429 | `rate_limited` | per-pubkey or per-IP rate limit; `Retry-After` SHOULD be set | +| 5xx | `internal` | sidecar internal error | + +The relay signals authorization failures through WebTransport +CONNECT response codes: + +| WT status | meaning | +|-----------|------------------------------------------------------------------------| +| 200 | session established | +| 401 | JWT signature invalid, expired, or fails the JWKS check | +| 403 | JWT `root` does not match path namespace, or `put` claim missing | +| 404 | path namespace unknown to the relay | + +#### Example + +```json +{ + "kind": 30312, + "pubkey": "abc...host", + "created_at": 1714003200, + "tags": [ + ["d", "office-hours-2026-04"], + ["title", "Office Hours"], + ["summary", "Weekly Q&A"], + ["status", "live"], + ["streaming", "https://moq.nostrnests.com:4443"], + ["auth", "https://moq-auth.nostrnests.com"], + ["p", "abc...host", "wss://relay.example", "host"], + ["p", "def...co", "wss://relay.example", "speaker"] + ], + "content": "", + "id": "...", + "sig": "..." +} +``` + +### User MoQ-Server List (kind 10112) + +A user MAY publish a list of nests servers they prefer to host on as +a NIP-01 *replaceable* event. Clients read this list to default-fill +the `streaming` and `auth` tags of a new `kind:30312` room. + +Each entry is a single tag carrying **two** URLs because the +moq-relay and moq-auth sidecar live on different hosts in the +deployed reference: + +```json +{ + "kind": 10112, + "pubkey": "", + "tags": [ + ["server", "", ""], + ["server", "", ""], + ... + ], + "content": "" +} +``` + +Tag elements are positional: index 1 is the moq-relay URL (becomes +the kind-30312 `streaming` tag value), index 2 is the moq-auth URL +(becomes the kind-30312 `auth` tag value). Order of tags is +preserved by receivers; earlier entries are higher priority. + +#### Back-compat + +Receivers MUST accept two looser shapes from earlier deployed +clients; publishers MUST emit only the canonical 3-element form: + +1. **First-element name `relay`.** Earliest NestsUI iterations + wrote `["relay", relay, auth]`. Treat as a synonym for `server`. +2. **Auth URL omitted.** A 2-element `["server", relay]` (or + `["relay", relay]`) carries no auth URL on the wire. Receivers + MUST derive it from the relay URL by replacing a leading `moq.` + host label with `moq-auth.`, or prepending `moq-auth.` when the + relay host has no `moq.` prefix. Drop the entry if the relay URL + is unparseable. + +#### Behavior + +1. Each `relay` and `auth` value MUST be a fully-qualified URL + beginning with `https://`. Receivers MUST drop entries that are + not well-formed HTTPS URLs. +2. Receivers MUST de-duplicate by exact-string match on the relay + URL after trimming a single trailing `/`. Order is preserved + (FIRST occurrence wins). +3. When the user opens a create-room sheet, the client SHOULD + pre-fill the kind-30312 `streaming` and `auth` tags from the + FIRST entry in the list. Clients MUST NOT collapse the two URLs + into a single field. +4. Users MAY enumerate up to 64 servers; receivers MUST tolerate + longer lists by truncating to the first 64. +5. The list is purely a defaults / discovery hint. A `kind:30312` + event's own `streaming` / `auth` tags are authoritative for that + specific room and override the user list at join time. + +#### Example + +```json +{ + "kind": 10112, + "pubkey": "abc...host", + "created_at": 1714003000, + "tags": [ + ["server", "https://moq.nostrnests.com:4443", "https://moq-auth.nostrnests.com"], + ["server", "https://relay.example.org:4443", "https://moq-auth.example.org"] + ], + "content": "", + "id": "...", + "sig": "..." +} +``` + +## Use Cases + +Common use cases include meeting room/conference calls, watch-together activities, audio spaces, hangouts, and game streams. + +## Notes + +Live Activity management events are not designed to be used by relays for filtering, so clients SHOULD use the addressable event (`::`) when referencing room messages. diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/meetingSpaces/MeetingSpaceEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/meetingSpaces/MeetingSpaceEvent.kt index e1ea88944..49eb99b83 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/meetingSpaces/MeetingSpaceEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/meetingSpaces/MeetingSpaceEvent.kt @@ -65,7 +65,7 @@ class MeetingSpaceEvent( fun status() = checkStatus(tags.firstNotNullOfOrNull(StatusTag::parseEnum)) - fun isLive() = status() != StatusTag.STATUS.CLOSED + fun isLive() = status() != StatusTag.STATUS.ENDED fun service() = tags.firstNotNullOfOrNull(ServiceUrlTag::parse) @@ -119,8 +119,8 @@ class MeetingSpaceEvent( fun participants() = tags.mapNotNull(ParticipantTag::parse) fun checkStatus(eventStatus: StatusTag.STATUS?): StatusTag.STATUS? = - if (eventStatus != StatusTag.STATUS.CLOSED && createdAt < TimeUtils.eightHoursAgo()) { - StatusTag.STATUS.CLOSED + if (eventStatus != StatusTag.STATUS.ENDED && createdAt < TimeUtils.eightHoursAgo()) { + StatusTag.STATUS.ENDED } else { eventStatus } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/meetingSpaces/tags/EndpointUrlTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/meetingSpaces/tags/EndpointUrlTag.kt index acc8b1966..a17dad857 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/meetingSpaces/tags/EndpointUrlTag.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/meetingSpaces/tags/EndpointUrlTag.kt @@ -23,17 +23,22 @@ package com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.tags import com.vitorpamplona.quartz.nip01Core.core.has import com.vitorpamplona.quartz.utils.ensure +/** + * URL of the moq-relay WebTransport endpoint for a NIP-53 / nests + * audio room. Matches the deployed nostrnests reference, which writes + * `["streaming", ""]` on `kind:30312`. The early EGG-01 + * draft called this `endpoint`; we accept that name on read for older + * events but always emit `streaming`. + */ class EndpointUrlTag { companion object Companion { - const val TAG_NAME = "endpoint" + const val TAG_NAME = "streaming" /** - * Legacy alias used by first-generation nostrnests web clients, - * which reused the NIP-53 streaming-event tag name for the - * kind-30312 MoQ relay URL. Accepted on read; we always emit - * the canonical [TAG_NAME]. + * Earlier EGG-01 draft name for the MoQ relay URL. Accepted on + * read for back-compat; we always emit the canonical [TAG_NAME]. */ - const val LEGACY_TAG_NAME = "streaming" + const val LEGACY_TAG_NAME = "endpoint" fun parse(tag: Array): String? { ensure(tag.has(1)) { return null } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/meetingSpaces/tags/ServiceUrlTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/meetingSpaces/tags/ServiceUrlTag.kt index 521a90ac0..03ff01a62 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/meetingSpaces/tags/ServiceUrlTag.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/meetingSpaces/tags/ServiceUrlTag.kt @@ -23,16 +23,22 @@ package com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.tags import com.vitorpamplona.quartz.nip01Core.core.has import com.vitorpamplona.quartz.utils.ensure +/** + * URL of the moq-auth sidecar (the JWT mint) for a NIP-53 / nests + * audio room. Matches the deployed nostrnests reference, which writes + * `["auth", ""]` on `kind:30312`. The early EGG-01 draft + * called this `service`; we accept that name on read for older events + * but always emit `auth`. + */ class ServiceUrlTag { companion object Companion { - const val TAG_NAME = "service" + const val TAG_NAME = "auth" /** - * Legacy alias used by first-generation nostrnests web clients - * for the moq-auth sidecar URL. Accepted on read; we always emit - * the canonical [TAG_NAME]. + * Earlier EGG-01 draft name for the moq-auth URL. Accepted on + * read for back-compat; we always emit the canonical [TAG_NAME]. */ - const val LEGACY_TAG_NAME = "auth" + const val LEGACY_TAG_NAME = "service" fun parse(tag: Array): String? { ensure(tag.has(1)) { return null } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/meetingSpaces/tags/StatusTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/meetingSpaces/tags/StatusTag.kt index b3a39cee8..c1dde55d0 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/meetingSpaces/tags/StatusTag.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/meetingSpaces/tags/StatusTag.kt @@ -24,14 +24,32 @@ import com.vitorpamplona.quartz.nip01Core.core.has import com.vitorpamplona.quartz.utils.ensure class StatusTag { + /** + * Canonical status codes match the deployed nostrnests reference: + * `live` for an in-progress room, `ended` for a closed one, + * `planned` for a scheduled future room. The earlier EGG-01 draft + * used `open` / `closed`; those values are accepted on read for + * back-compat but never emitted. + * + * `private` is a forward-looking value for invite-only rooms; it + * is not yet emitted by the deployed reference but is reserved on + * the spec side so the auth sidecar's `not_invited` error has a + * room status to anchor against. + */ enum class STATUS( val code: String, ) { /** Scheduled in the future — host hasn't started the room yet. Pairs with a `["starts", ]` tag. */ PLANNED("planned"), - OPEN("open"), + + /** Live, in-progress room (formerly `open` in the EGG-01 draft). */ + LIVE("live"), + + /** Reserved for invite-only rooms; not yet emitted by deployed clients. */ PRIVATE("private"), - CLOSED("closed"), + + /** Room is over (formerly `closed` in the EGG-01 draft). */ + ENDED("ended"), ; fun toTagArray() = assemble(this) @@ -41,20 +59,18 @@ class StatusTag { when (code) { PLANNED.code -> PLANNED - OPEN.code -> OPEN + LIVE.code -> LIVE PRIVATE.code -> PRIVATE - CLOSED.code -> CLOSED + ENDED.code -> ENDED - // Legacy aliases used by first-generation nostrnests - // web clients that reused NIP-53 streaming-event - // status values for kind-30312 meeting spaces. - // Accept on read to preserve interop; we always emit - // the canonical EGG-01 codes. - "live" -> OPEN + // Earlier EGG-01 draft used "open" / "closed". Accept + // on read for back-compat; we always emit the deployed + // nostrnests codes ("live" / "ended"). + "open" -> LIVE - "ended" -> CLOSED + "closed" -> ENDED else -> null } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/nestsServers/NestsServersEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/nestsServers/NestsServersEvent.kt index d62bbccef..90fdc09e0 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/nestsServers/NestsServersEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/nestsServers/NestsServersEvent.kt @@ -29,40 +29,53 @@ import com.vitorpamplona.quartz.nip01Core.tags.aTag.ATag import com.vitorpamplona.quartz.nip31Alts.AltTag import com.vitorpamplona.quartz.utils.TimeUtils +/** + * One audio-room server entry as published in `kind:10112`. Each entry + * names a deployment by its **two** distinct URLs: the moq-relay + * WebTransport endpoint ([relay], goes into the kind-30312 `streaming` + * tag) and the moq-auth sidecar base URL ([auth], goes into the + * kind-30312 `auth` tag). The deployed nostrnests reference puts these + * on different hosts (`moq.nostrnests.com:4443` for the relay, + * `moq-auth.nostrnests.com` for auth) so the pair MUST be carried + * together — collapsing them to a single URL breaks the JWT-mint flow + * (the auth host doesn't accept TCP on the relay port and vice-versa). + */ +@Immutable +data class NestsServer( + val relay: String, + val auth: String, +) + /** * Replaceable event listing the audio-room (NIP-53 / nests) MoQ host * servers a user prefers to publish their kind-30312 spaces against. * * Kind: **10112** — declared by nostrnests's reference README under * "User-published audio server lists". Wire shape mirrors - * BlossomServersEvent's 10063 layout (one `server` tag per host base - * URL). + * BlossomServersEvent's 10063 layout, but every `server` entry carries + * **three** elements (tag name, relay URL, auth URL): * * { * "kind": 10112, * "tags": [ * ["alt", "Audio-room (nests) MoQ servers used by the author"], - * ["server", "https://moq-auth.nostrnests.com"], - * ["server", "https://moq.example.org"], + * ["server", "https://moq.nostrnests.com:4443", "https://moq-auth.nostrnests.com"], + * ["server", "https://relay.example.org:4443", "https://moq-auth.example.org"], * ... * ], * "content": "" * } * - * The `server` URL is the moq-auth (JWT mint) base — that's also the - * URL that goes into the kind-30312 `service` tag. The matching - * WebTransport relay endpoint is NOT part of this list; reference - * deployments like nostrnests run the auth sidecar on regular HTTPS - * (e.g. `https://moq-auth.nostrnests.com`) and the QUIC relay on a - * separate host/port (e.g. `https://moq.nostrnests.com:4443`), so - * Amethyst's `CreateNestSheet` resolves the pair via a known-deployment - * mapping (with a fallback that reuses the saved URL for both tags - * when a community deployment genuinely co-locates them). A future - * revision can split the two into separate tag fields if needed. + * On the read side we tolerate two legacy shapes the deployed + * nostrnests reference also accepts: + * - First-element name `relay` instead of `server` (earliest + * iteration of the React app). + * - 2-element form `["server", relay]` with the auth URL omitted. + * Receivers MUST derive the auth host: replace a leading `moq.` + * with `moq-auth.`, or prepend `moq-auth.` when no `moq.` prefix + * is present. * - * This event is shaped 1:1 after `BlossomServersEvent` (kind 10063, - * NIP-B7) so existing list-state / settings UI patterns translate - * directly. + * Authoritative reference: NestsUI-v2 `useMoqServerList` hook. */ @Immutable class NestsServersEvent( @@ -73,13 +86,18 @@ class NestsServersEvent( content: String, sig: HexKey, ) : BaseReplaceableEvent(id, pubKey, createdAt, KIND, tags, content, sig) { - fun servers(): List = - tags.mapNotNull { - if (it.size > 1 && it[0] == "server") { - it[1] - } else { - null - } + /** + * Decode every well-formed `server` (or legacy `relay`) tag into a + * [NestsServer] pair. A 2-element form gets its auth URL derived + * via [deriveAuthUrl]; a fully-malformed tag is dropped. + */ + fun servers(): List = + tags.mapNotNull { tag -> + if (tag.size < 2) return@mapNotNull null + if (tag[0] != "server" && tag[0] != "relay") return@mapNotNull null + val relay = tag[1].takeIf { it.isNotBlank() } ?: return@mapNotNull null + val auth = tag.getOrNull(2)?.takeIf { it.isNotBlank() } ?: deriveAuthUrl(relay) + NestsServer(relay = relay, auth = auth ?: return@mapNotNull null) } companion object { @@ -92,37 +110,71 @@ class NestsServersEvent( fun createAddressTag(pubKey: HexKey): String = Address.assemble(KIND, pubKey, FIXED_D_TAG) - fun createTagArray(servers: List): Array> = + fun createTagArray(servers: List): Array> = servers - .map { arrayOf("server", it) } + .map { arrayOf("server", it.relay, it.auth) } .plusElement(AltTag.assemble(ALT)) .toTypedArray() suspend fun updateRelayList( earlierVersion: NestsServersEvent, - servers: List, + servers: List, signer: NostrSigner, createdAt: Long = TimeUtils.now(), ): NestsServersEvent { val tags = earlierVersion.tags - .filter { it[0] != "server" } - .plus(servers.map { arrayOf("server", it) }) + .filter { it[0] != "server" && it[0] != "relay" } + .plus(servers.map { arrayOf("server", it.relay, it.auth) }) .toTypedArray() return signer.sign(createdAt, KIND, tags, earlierVersion.content) } suspend fun createFromScratch( - relays: List, + servers: List, signer: NostrSigner, createdAt: Long = TimeUtils.now(), - ) = create(relays, signer, createdAt) + ) = create(servers, signer, createdAt) suspend fun create( - servers: List, + servers: List, signer: NostrSigner, createdAt: Long = TimeUtils.now(), ) = signer.sign(createdAt, KIND, createTagArray(servers), "") + + /** + * Derive the moq-auth base URL from a moq-relay URL when a + * legacy 2-element `server` tag omits the auth URL. Mirrors + * the deployed nostrnests fallback in + * `NestsUI-v2/useMoqServerList`: + * + * - Replace a leading `moq.` host label with `moq-auth.` + * - Otherwise prepend `moq-auth.` to the existing host + * - Strip the relay's explicit port (the auth sidecar always + * lives on regular HTTPS / port 443) + * + * Returns null when the input isn't a parseable + * `://[:port][/...]` URL — caller drops the entry. + */ + fun deriveAuthUrl(relayUrl: String): String? { + val schemeEnd = relayUrl.indexOf("://") + if (schemeEnd <= 0) return null + val scheme = relayUrl.substring(0, schemeEnd) + val rest = relayUrl.substring(schemeEnd + 3) + val pathSlash = rest.indexOf('/') + val authority = if (pathSlash < 0) rest else rest.substring(0, pathSlash) + if (authority.isEmpty()) return null + val portColon = authority.indexOf(':') + val host = if (portColon < 0) authority else authority.substring(0, portColon) + if (host.isEmpty()) return null + val derivedHost = + if (host.startsWith("moq.")) { + "moq-auth." + host.substring("moq.".length) + } else { + "moq-auth.$host" + } + return "$scheme://$derivedHost" + } } } diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/meetingSpaces/MeetingSpaceEventBuildTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/meetingSpaces/MeetingSpaceEventBuildTest.kt index 63fa5a82d..540de9256 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/meetingSpaces/MeetingSpaceEventBuildTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/meetingSpaces/MeetingSpaceEventBuildTest.kt @@ -37,7 +37,7 @@ class MeetingSpaceEventBuildTest { val template = MeetingSpaceEvent.build( room = "Main Hall", - status = StatusTag.STATUS.OPEN, + status = StatusTag.STATUS.LIVE, service = "https://meet.example.com/hall", host = ParticipantTag(host, null, "Host", null), dTag = "main-hall", @@ -53,9 +53,9 @@ class MeetingSpaceEventBuildTest { assertEquals("main-hall", byName["d"]?.single()?.get(1)) assertEquals("Main Hall", byName["room"]?.single()?.get(1)) - assertEquals("open", byName["status"]?.single()?.get(1)) - assertEquals("https://meet.example.com/hall", byName["service"]?.single()?.get(1)) - assertEquals("https://api.example.com/hall", byName["endpoint"]?.single()?.get(1)) + assertEquals("live", byName["status"]?.single()?.get(1)) + assertEquals("https://meet.example.com/hall", byName["auth"]?.single()?.get(1)) + assertEquals("https://api.example.com/hall", byName["streaming"]?.single()?.get(1)) assertEquals("The big room", byName["summary"]?.single()?.get(1)) assertEquals("https://example.com/hall.png", byName["image"]?.single()?.get(1)) assertEquals(MeetingSpaceEvent.ALT, byName["alt"]?.single()?.get(1)) From 0fafd34537aab47b37048176c7894863e280d795 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 29 Apr 2026 20:45:32 +0000 Subject: [PATCH 3/5] fix(nests): emit canonical title tag and keep mute badge visible while muted MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two interrelated bugs from the deployed-schema flip: 1. Rooms created in Amethyst didn't appear in nostrnests's "Live Now" lobby. The lobby filter in NestsUI's skeleton.js requires a `title` tag on every kind:30312 — events without one are dropped from `live`/`planned`/`ended`. RoomNameTag still emitted the legacy `room` name, so our rooms passed our own readers (which tolerate both names) but were invisible to NestsUI. Flip canonical to `title`, keep `room` as legacy on read. 2. The mute badge over the avatar disappeared shortly after toggling mute, even though the broadcaster was still muted. Two causes: - ParticipantsGrid gated both the muted ring and the MicStateBadge on `member.publishing`. But per the deployed kind-10312 semantics `publishing=0` whenever `muted=1` (NestRoomPresencePublisher mirrors this — `publishingNow = broadcasting && !isMuted`), so the badge that was supposed to *indicate* mute was hidden exactly when it became relevant. Show the badge when on stage and either publishing OR muted; gate the muted ring on `muted` alone. - The presence heartbeat captured `micMutedTag` / `publishingTag` / `onstageTag` / `handRaised` at LaunchedEffect launch and never refreshed them. The 500 ms debounced LaunchedEffect did publish the new mute state, but ~30 s later the heartbeat overwrote it with the captured pre-toggle values, flipping `publishing` back to 1 and `muted` back to 0 server-side. Wrap the dynamic fields in `rememberUpdatedState` so the heartbeat reads the latest snapshot on every refresh; keep the existing key set (event/handRaised/onstage) for prompt re-emission on those transitions. Tests updated for the new canonical `title` emission; both jvmTest suites pass. --- .../lifecycle/NestRoomPresencePublisher.kt | 25 +++++++++++++++---- .../nests/room/stage/ParticipantsGrid.kt | 15 +++++++++-- .../nests/room/edit/EditNestViewModelTest.kt | 8 ++++-- .../meetingSpaces/tags/RoomNameTag.kt | 17 +++++++++---- .../MeetingSpaceEventBuildTest.kt | 2 +- 5 files changed, 52 insertions(+), 15 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/lifecycle/NestRoomPresencePublisher.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/lifecycle/NestRoomPresencePublisher.kt index 54de9d138..e148e3d0b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/lifecycle/NestRoomPresencePublisher.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/lifecycle/NestRoomPresencePublisher.kt @@ -23,6 +23,8 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.nests.room.lifecycle import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.rememberUpdatedState import com.vitorpamplona.amethyst.commons.viewmodels.BroadcastUiState import com.vitorpamplona.amethyst.commons.viewmodels.NestUiState import com.vitorpamplona.amethyst.model.Account @@ -43,9 +45,12 @@ import kotlinx.coroutines.launch * onstage transition, then every [PRESENCE_REFRESH_MS]. Mute * and publishing flags are intentionally NOT keys: every mute * toggle would otherwise round-trip a presence emit (audit - * Android #11). The next heartbeat picks them up within 30 s, - * which is well within the user's "did the peer see my mute" - * tolerance. + * Android #11). The heartbeat reads them via + * [rememberUpdatedState] so each refresh picks up the latest + * value WITHOUT cancelling and restarting the loop — without + * that wrapper the captured-at-launch values stay frozen and + * a 30 s refresh would overwrite a fresh mute toggle with the + * pre-toggle state, hiding the avatar's mute icon. * * - **Debounce** — after a mute toggle, wait * [PRESENCE_DEBOUNCE_MS] for further changes before sending a @@ -77,11 +82,21 @@ internal fun NestPresencePublisher( val publishingTag: Boolean = ui.publishingNow val onstageTag: Boolean = ui.onStageNow + // Latest snapshots for the heartbeat. Without these, the + // LaunchedEffect captures the values from FIRST composition; the + // 30 s refresh would then overwrite a recent mute toggle with the + // pre-toggle state, which presented as "the mute icon disappeared + // a few seconds after I muted, but the mic is still hot." + val currentHandRaised by rememberUpdatedState(handRaised) + val currentMicMuted by rememberUpdatedState(micMutedTag) + val currentPublishing by rememberUpdatedState(publishingTag) + val currentOnstage by rememberUpdatedState(onstageTag) + LaunchedEffect(event.address().toValue(), handRaised, onstageTag) { - publishPresence(account, event, handRaised, micMutedTag, publishingTag, onstageTag) + publishPresence(account, event, currentHandRaised, currentMicMuted, currentPublishing, currentOnstage) while (isActive) { delay(PRESENCE_REFRESH_MS) - publishPresence(account, event, handRaised, micMutedTag, publishingTag, onstageTag) + publishPresence(account, event, currentHandRaised, currentMicMuted, currentPublishing, currentOnstage) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/stage/ParticipantsGrid.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/stage/ParticipantsGrid.kt index c38cae46d..f747a500e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/stage/ParticipantsGrid.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/stage/ParticipantsGrid.kt @@ -355,10 +355,15 @@ private fun MemberCell( // Both color and width crossfade so going idle → speaking → idle // doesn't snap; the color animates from Transparent through the // speaking green and the width tracks the live peak amplitude. + // A speaker on stage with mic-mute on emits kind-10312 with + // `publishing=0, muted=1` (deployed nostrnests semantics — see + // EGG-04 / NestRoomPresencePublisher), so the muted ring must NOT + // gate on `publishing`; muting would otherwise hide the very + // indicator it was supposed to surface. val targetRingColor = when { isSpeaking -> NEST_SPEAKING_COLOR - showMicBadge && member.publishing && member.muted == true -> mutedRingColor + showMicBadge && member.muted == true -> mutedRingColor else -> Color.Transparent } val targetRingWidth = @@ -445,7 +450,13 @@ private fun MemberCell( modifier = Modifier.align(Alignment.TopEnd), ) } - if (showMicBadge && member.publishing) { + // Show the mic badge for any on-stage speaker that has + // an audio state to surface — currently broadcasting + // (`publishing=1`) OR mic-muted (`muted=1, publishing=0`). + // Gating only on `publishing` would hide the muted icon + // the moment the user mutes, which is exactly when it's + // supposed to appear. + if (showMicBadge && (member.publishing || member.muted == true)) { MicStateBadge( isSpeaking = isSpeaking, isMuted = member.muted == true, diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/edit/EditNestViewModelTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/edit/EditNestViewModelTest.kt index 46db127bc..a14ef80fc 100644 --- a/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/edit/EditNestViewModelTest.kt +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/edit/EditNestViewModelTest.kt @@ -91,8 +91,12 @@ class EditNestViewModelTest { val dTag = template.tags.firstOrNull { it.firstOrNull() == "d" }?.getOrNull(1) assertEquals("rt-42", dTag) - val roomTag = template.tags.firstOrNull { it.firstOrNull() == "room" }?.getOrNull(1) - assertEquals("New name", roomTag) + // The rebuilt template emits the canonical `title` tag (per the + // deployed nostrnests reference); a legacy `room` tag from the + // source event MUST be dropped, not duplicated. + val titleTag = template.tags.firstOrNull { it.firstOrNull() == "title" }?.getOrNull(1) + assertEquals("New name", titleTag) + assertNull(template.tags.firstOrNull { it.firstOrNull() == "room" }) val summaryTag = template.tags.firstOrNull { it.firstOrNull() == "summary" }?.getOrNull(1) assertEquals("New summary", summaryTag) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/meetingSpaces/tags/RoomNameTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/meetingSpaces/tags/RoomNameTag.kt index 3a4b03179..0d21a9adb 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/meetingSpaces/tags/RoomNameTag.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/meetingSpaces/tags/RoomNameTag.kt @@ -23,17 +23,24 @@ package com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.tags import com.vitorpamplona.quartz.nip01Core.core.has import com.vitorpamplona.quartz.utils.ensure +/** + * Room display name on a NIP-53 / nests `kind:30312` event. Matches + * the deployed nostrnests reference, which writes `["title", name]` + * AND filters its lobby on the `title` tag's presence — kind-30312 + * events without a `title` are dropped from "Live Now". The early + * EGG-01 draft called this `room`; we accept that name on read for + * older events but always emit `title`. + */ class RoomNameTag { companion object Companion { - const val TAG_NAME = "room" + const val TAG_NAME = "title" /** - * Legacy alias used by first-generation nostrnests web clients, - * which reused the NIP-53 streaming-event `title` tag for the - * kind-30312 room name. Accepted on read; we always emit the + * Earlier EGG-01 draft name for the room display name. + * Accepted on read for back-compat; we always emit the * canonical [TAG_NAME]. */ - const val LEGACY_TAG_NAME = "title" + const val LEGACY_TAG_NAME = "room" fun parse(tag: Array): String? { ensure(tag.has(1)) { return null } diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/meetingSpaces/MeetingSpaceEventBuildTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/meetingSpaces/MeetingSpaceEventBuildTest.kt index 540de9256..1b643f5ba 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/meetingSpaces/MeetingSpaceEventBuildTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/meetingSpaces/MeetingSpaceEventBuildTest.kt @@ -52,7 +52,7 @@ class MeetingSpaceEventBuildTest { val byName = template.tags.groupBy { it[0] } assertEquals("main-hall", byName["d"]?.single()?.get(1)) - assertEquals("Main Hall", byName["room"]?.single()?.get(1)) + assertEquals("Main Hall", byName["title"]?.single()?.get(1)) assertEquals("live", byName["status"]?.single()?.get(1)) assertEquals("https://meet.example.com/hall", byName["auth"]?.single()?.get(1)) assertEquals("https://api.example.com/hall", byName["streaming"]?.single()?.get(1)) From 2fae726e5e16842cd56ba861dd03193d211d4701 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 29 Apr 2026 20:53:38 +0000 Subject: [PATCH 4/5] fix(nests): place Audience/Hands tab counter beside the label, not over it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PrimaryTabRow's BadgedBox places the count badge in the top-end corner of its anchor, which on tight tab labels lands directly on top of the text. Lay them out as a Row instead so "Audience 3" reads cleanly, matching the tabbar comment's "Hands · 3" intent. --- .../nests/room/screen/NestFullScreen.kt | 24 +++++++++++-------- 1 file changed, 14 insertions(+), 10 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/screen/NestFullScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/screen/NestFullScreen.kt index 15a97ae77..1546caabd 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/screen/NestFullScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/screen/NestFullScreen.kt @@ -21,8 +21,10 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.nests.room.screen import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.consumeWindowInsets import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth @@ -30,7 +32,6 @@ import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.padding import androidx.compose.material3.AlertDialog import androidx.compose.material3.Badge -import androidx.compose.material3.BadgedBox import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem @@ -51,6 +52,7 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext @@ -422,16 +424,18 @@ private fun NestTabRow( selected = tab == selectedTab, onClick = { onSelect(tab) }, text = { - if (count > 0) { - BadgedBox( - badge = { - Badge { Text(text = count.toString()) } - }, - ) { - Text(text = label) - } - } else { + // Lay the label and the count badge side-by-side so + // the count reads as "Audience 3" instead of being + // overlapped on top of the label by `BadgedBox`'s + // default top-end placement (audit Android #43). + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { Text(text = label) + if (count > 0) { + Badge { Text(text = count.toString()) } + } } }, ) From 2d45c6ff4a6d108e5784cf513a5da6988e7f1cd8 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 29 Apr 2026 20:56:33 +0000 Subject: [PATCH 5/5] fix(quic): make SendBuffer thread-safe to stop torn-state crash QuicConnectionDriver.sendLoop holds the connection mutex while it calls SendBuffer.takeChunk via QuicConnectionWriter.drainOutbound, but WtPeerStreamDemux's per-stream `send` callback calls SendBuffer.enqueue from arbitrary application coroutines without the connection lock. The two paths concurrently mutate (chunks, pendingBytes, headOffset, finPending, finSent, sentEnd, nextOffset). Under load this surfaced as java.util.NoSuchElementException: ArrayDeque is empty. at kotlin.collections.ArrayDeque.first(ArrayDeque.kt:102) at com.vitorpamplona.quic.stream.SendBuffer.takeChunk(SendBuffer.kt:85) at com.vitorpamplona.quic.connection.QuicConnectionWriterKt.buildApplicationPacket(...) at com.vitorpamplona.quic.connection.QuicConnectionDriver.sendLoop(...) The writer saw `pendingBytes > 0` (incremented by an in-flight enqueue on another thread) before the matching `chunks.addLast` became visible, fell into the head-peel branch, and tripped on chunks.first(). Wrap every read and write of SendBuffer state in `synchronized(this)`, including the cheap `readableBytes` / `sentOffset` / `finPending` / `finSent` getters used by the writer's pre-flight checks (so they can't read torn state either). The lock is uncontended in the common case and short-held in the rare race; we already use synchronized blocks elsewhere in commonMain (QuicConnectionDriver.kt). --- .../vitorpamplona/quic/stream/SendBuffer.kt | 110 ++++++++++-------- 1 file changed, 64 insertions(+), 46 deletions(-) diff --git a/quic/src/commonMain/kotlin/com/vitorpamplona/quic/stream/SendBuffer.kt b/quic/src/commonMain/kotlin/com/vitorpamplona/quic/stream/SendBuffer.kt index 2a6f1374e..58b42dde4 100644 --- a/quic/src/commonMain/kotlin/com/vitorpamplona/quic/stream/SendBuffer.kt +++ b/quic/src/commonMain/kotlin/com/vitorpamplona/quic/stream/SendBuffer.kt @@ -37,6 +37,20 @@ package com.vitorpamplona.quic.stream * items in `quic/plans/2026-04-26-quic-stack-status.md` — adding * retain-until-ACK + retransmit is the first thing to add for general * STREAM-heavy use. + * + * **Concurrency:** [enqueue] / [finish] are invoked by application + * coroutines (e.g. WebTransport stream writers in [com.vitorpamplona.quic.webtransport.WtPeerStreamDemux]); + * [takeChunk] runs on the [com.vitorpamplona.quic.connection.QuicConnectionDriver] send loop under the + * connection mutex. The two paths are NOT serialised by a shared lock, + * so the buffer's internal state (`chunks`, `pendingBytes`, `headOffset`, + * FIN flags, offsets) is mutated under `synchronized(this)`. The cheap + * `readableBytes` / `sentOffset` / `finPending` / `finSent` getters used + * by the writer's pre-flight checks are also synchronised so they can't + * read torn state. Without this, an `enqueue` racing a `takeChunk` + * surfaced as `NoSuchElementException: ArrayDeque is empty` from + * `chunks.first()` (the writer saw `pendingBytes > 0` after the + * `addLast` but before the matching deque mutation became visible, so + * it entered the head-peel branch and tripped on an empty deque). */ class SendBuffer { /** @@ -49,68 +63,72 @@ class SendBuffer { private var headOffset: Int = 0 private var pendingBytes: Int = 0 private var sentEnd: Long = 0L - var nextOffset: Long = 0L - private set - var finPending: Boolean = false - private set - var finSent: Boolean = false - private set + private var _nextOffset: Long = 0L + private var _finPending: Boolean = false + private var _finSent: Boolean = false - val readableBytes: Int get() = pendingBytes + val nextOffset: Long get() = synchronized(this) { _nextOffset } + val finPending: Boolean get() = synchronized(this) { _finPending } + val finSent: Boolean get() = synchronized(this) { _finSent } + + val readableBytes: Int get() = synchronized(this) { pendingBytes } /** Bytes already handed out via [takeChunk]; equal to the next offset to assign. */ - val sentOffset: Long get() = sentEnd + val sentOffset: Long get() = synchronized(this) { sentEnd } fun enqueue(bytes: ByteArray) { if (bytes.isEmpty()) return - chunks.addLast(bytes) - pendingBytes += bytes.size - nextOffset += bytes.size + synchronized(this) { + chunks.addLast(bytes) + pendingBytes += bytes.size + _nextOffset += bytes.size + } } /** Mark the write side as closing; the next [takeChunk] will set FIN once empty. */ fun finish() { - finPending = true + synchronized(this) { _finPending = true } } /** Take up to [maxBytes] bytes off the head of the buffer at the current send offset. */ - fun takeChunk(maxBytes: Int): Chunk? { - if (pendingBytes == 0 && !(finPending && !finSent)) return null - val cap = maxBytes.coerceAtLeast(0) - if (cap == 0 && pendingBytes > 0) return null - val data: ByteArray - if (pendingBytes == 0) { - data = ByteArray(0) - } else { - val head = chunks.first() - val available = head.size - headOffset - if (available <= cap) { - // Hand out the rest of the head chunk. Always copy: the caller's - // ByteArray (passed to enqueue) MUST stay opaque to the rest of - // the stack, since downstream encoders eventually pass it to - // AEAD.seal which assumes immutability for the duration of the - // encryption call. - data = - if (headOffset == 0 && head.size == available) { - head.copyOf() - } else { - head.copyOfRange(headOffset, head.size) - } - chunks.removeFirst() - headOffset = 0 - pendingBytes -= available + fun takeChunk(maxBytes: Int): Chunk? = + synchronized(this) { + if (pendingBytes == 0 && !(_finPending && !_finSent)) return@synchronized null + val cap = maxBytes.coerceAtLeast(0) + if (cap == 0 && pendingBytes > 0) return@synchronized null + val data: ByteArray + if (pendingBytes == 0) { + data = ByteArray(0) } else { - data = head.copyOfRange(headOffset, headOffset + cap) - headOffset += cap - pendingBytes -= cap + val head = chunks.first() + val available = head.size - headOffset + if (available <= cap) { + // Hand out the rest of the head chunk. Always copy: the caller's + // ByteArray (passed to enqueue) MUST stay opaque to the rest of + // the stack, since downstream encoders eventually pass it to + // AEAD.seal which assumes immutability for the duration of the + // encryption call. + data = + if (headOffset == 0 && head.size == available) { + head.copyOf() + } else { + head.copyOfRange(headOffset, head.size) + } + chunks.removeFirst() + headOffset = 0 + pendingBytes -= available + } else { + data = head.copyOfRange(headOffset, headOffset + cap) + headOffset += cap + pendingBytes -= cap + } } + val offset = sentEnd + sentEnd += data.size + val fin = _finPending && pendingBytes == 0 + if (fin) _finSent = true + Chunk(offset, data, fin) } - val offset = sentEnd - sentEnd += data.size - val fin = finPending && pendingBytes == 0 - if (fin) finSent = true - return Chunk(offset, data, fin) - } data class Chunk( val offset: Long,