From aadb347b84147b3553544d17f00cdbea167d4f28 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 29 Apr 2026 19:32:53 +0000 Subject: [PATCH] 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))