Merge pull request #2646 from vitorpamplona/claude/fix-nest-room-connection-H48ad

Align nests servers with NIP-53 spec: separate relay and auth URLs
This commit is contained in:
Vitor Pamplona
2026-04-29 17:03:27 -04:00
committed by GitHub
34 changed files with 997 additions and 280 deletions
@@ -3087,7 +3087,7 @@ class Account(
suspend fun sendBlossomServersList(servers: List<String>) = sendMyPublicAndPrivateOutbox(blossomServers.saveBlossomServersList(servers))
suspend fun sendNestsServersList(servers: List<String>) = sendMyPublicAndPrivateOutbox(nestsServers.saveNestsServersList(servers))
suspend fun sendNestsServersList(servers: List<com.vitorpamplona.quartz.nip53LiveActivities.nestsServers.NestsServer>) = sendMyPublicAndPrivateOutbox(nestsServers.saveNestsServersList(servers))
suspend fun savePaymentTargets(targets: List<PaymentTarget>) = sendMyPublicAndPrivateOutbox(paymentTargetsState.savePaymentTargets(targets))
@@ -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<String>` of saved server base URLs
* - [flow] — current `List<NestsServer>` of saved (relay, auth) pairs
* - [getNestsServersListFlow] — reactive `StateFlow<NoteState>` 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<String> {
fun normalizeServers(note: Note): List<NestsServer> {
val event = note.event as? NestsServersEvent
return event?.servers() ?: emptyList()
}
val flow: StateFlow<List<String>> =
val flow: StateFlow<List<NestsServer>> =
getNestsServersListFlow()
.map {
normalizeServers(it.note)
@@ -80,7 +82,7 @@ class NestsServerListState(
emptyList(),
)
suspend fun saveNestsServersList(servers: List<String>): NestsServersEvent {
suspend fun saveNestsServersList(servers: List<NestsServer>): 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,
)
}
@@ -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,12 +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.
*
* 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<NestsServer> =
val DEFAULT_NESTS_SERVERS: List<NestsServerEntry> =
listOf(
NestsServer(name = "nostrnests.com", baseUrl = "https://moq.nostrnests.com:4443"),
NestsServerEntry(
name = "nostrnests.com",
relay = "https://moq.nostrnests.com:4443",
auth = "https://moq-auth.nostrnests.com",
),
)
@@ -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<String>` 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<List<NestsServer>>(emptyList())
private val _servers = MutableStateFlow<List<NestsServerEntry>>(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<String>) {
urls.forEach { addServer(it) }
fun addServerList(servers: List<NestsServerEntry>) {
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,25 +121,45 @@ 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.host(url).removePrefix("moq.").removePrefix("nests.")
Rfc3986
.host(url)
.removePrefix("moq-auth.")
.removePrefix("moq.")
.removePrefix("nests.")
} 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 (`https://moq.nostrnests.com`)
* - [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,
)
@@ -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)
}
@@ -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
}
}
@@ -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,
@@ -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
}
@@ -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
@@ -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
}
}
@@ -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()
}
@@ -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) {
@@ -74,12 +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) {
_state.update { it.copy(serviceUrl = savedFirst, endpointUrl = savedFirst) }
if (first != null && first.relay.startsWith("http") && first.auth.startsWith("http")) {
_state.update { it.copy(serviceUrl = first.auth, endpointUrl = first.relay) }
}
}
@@ -249,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(),
@@ -355,8 +354,17 @@ 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) 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. 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.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"
}
}
@@ -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
}
@@ -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
@@ -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)
}
}
@@ -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()
}
@@ -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(
@@ -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()) }
}
}
},
)
@@ -552,7 +556,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) {
@@ -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,
+5
View File
@@ -610,6 +610,11 @@
<string name="nests_servers_my_section">Your servers</string>
<string name="nests_servers_my_explainer">Saved as a kind-10112 replaceable event so other clients can read your preference.</string>
<string name="nests_servers_add_field">Add a nest server</string>
<string name="nests_servers_add_relay_field">Relay (WebTransport) URL</string>
<string name="nests_servers_add_auth_field">Auth (JWT mint) URL</string>
<string name="nests_servers_add_pair_button">Add</string>
<string name="nests_servers_relay_label">Relay</string>
<string name="nests_servers_auth_label">Auth</string>
<string name="nests_servers_recommended_section">Recommended servers</string>
<string name="nests_servers_recommended_explainer">Built-in suggestions you can add to your list with one tap.</string>
<string name="nests_servers_use_defaults">Use Amethyst defaults</string>
@@ -44,7 +44,7 @@ class EditNestViewModelTest {
buildList<Array<String>> {
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,14 +85,18 @@ 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)
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)
@@ -113,7 +117,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 +133,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 +147,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.
+55 -27
View File
@@ -20,12 +20,12 @@ Every other EGG layers on top of this event.
"pubkey": "<host pubkey hex>",
"tags": [
["d", "<room id>"],
["room", "<room name>"],
["title", "<room name>"],
["summary", "<one-line description>"],
["image", "<optional cover image URL>"],
["status", "open" | "private" | "closed" | "planned"],
["service", "<https URL of moq-auth sidecar>"],
["endpoint", "<https URL of moq-relay WebTransport>"],
["status", "live" | "private" | "ended" | "planned"],
["streaming", "<https URL of moq-relay WebTransport>"],
["auth", "<https URL of moq-auth sidecar>"],
["relays", "<wss relay 1>", "<wss relay 2>", ...], // ONE tag, multiple values
["p", "<pubkey>", "<relay hint>", "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"]
],
+16 -12
View File
@@ -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 <service>/auth
POST <auth>/auth
Authorization: Nostr <base64(NIP-98 kind:27235 event JSON)>
Content-Type: application/json; charset=utf-8
@@ -29,8 +31,10 @@ Content-Type: application/json; charset=utf-8
`<kind>` is `30312` for nests audio rooms (EGG-01).
The `<service>` URL is the EGG-01 `service` tag value with any trailing `/`
stripped, then `/auth` appended literally.
The `<auth>` 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 — `<auth>` here is just the
sidecar base URL, so the full request line is `POST <auth-base>/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": "<requester pubkey hex>",
"created_at": <unix seconds>,
"tags": [
["u", "<service>/auth"], // exact request URL, scheme included
["u", "<auth>/auth"], // exact request URL, scheme included
["method", "POST"],
["payload", "<sha256 of request body, lowercase hex>"]
],
@@ -91,7 +95,7 @@ RFC 7518 §3.4). The auth sidecar MUST publish its public verification keys
as a JWKS at:
```
GET <service>/.well-known/jwks.json
GET <auth>/.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 = <host:port from `endpoint`>
:authority = <host:port from `streaming`>
:path = /<namespace>?jwt=<token>
```
@@ -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
>
+55 -22
View File
@@ -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": "<user pubkey hex>",
"tags": [
["server", "<https URL — moq-auth/moq-relay base>"],
["server", "<https URL>"],
["server", "<https URL of moq-relay>", "<https URL of moq-auth>"],
["server", "<https URL of moq-relay>", "<https URL of moq-auth>"],
...
],
"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": "...",
+349
View File
@@ -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", "<unique identifier>"],
["title", "<name of the event>"],
["summary", "<description>"],
["image", "<preview image url>"],
["t", "hashtag"],
["streaming", "<url>"],
["recording", "<url>"],
["starts", "<unix timestamp in seconds>"],
["ends", "<unix timestamp in seconds>"],
["status", "<planned, live, ended>"],
["current_participants", "<number>"],
["total_participants", "<number>"],
["p", "<pubkey>", "<relay url>", "<role>", "<proof>"],
["relays", "<relay 1>", "<relay 2>", ...]
]
}
```
### 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": "<host pubkey hex>",
"tags": [
["d", "<room id>"],
["title", "<room name>"],
["summary", "<one-line description>"],
["image", "<optional cover image URL>"],
["status", "live" | "private" | "ended" | "planned"],
["streaming", "<https URL of moq-relay WebTransport>"],
["auth", "<https URL of moq-auth sidecar>"],
["starts", "<unix timestamp in seconds>"],
["color", "<gradient identifier or hex>"],
["relays", "<wss relay 1>", "<wss relay 2>", ...],
["p", "<pubkey>", "<relay hint>", "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", <pubkey hex>, <relay hint>, <role>, <proof>]`
`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>/auth
Authorization: Nostr <base64(NIP-98 kind:27235 event JSON)>
Content-Type: application/json; charset=utf-8
{ "namespace": "nests/30312:<host_pubkey_hex>:<room_d>",
"publish": <boolean> }
```
`<auth>` 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>/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": "<jwt>" }
```
The JWT is signed `alg: ES256`. The sidecar MUST publish its public
keys at `GET <auth>/.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: `[<pubkey>]`. |
| `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 = <host:port from `streaming`>
:path = /<namespace>?jwt=<token>
```
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": "<slug>", "reason": "<human string>" }` 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/<kind>:<hexpubkey>:<d>` |
| 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": "<user pubkey hex>",
"tags": [
["server", "<https URL of moq-relay>", "<https URL of moq-auth>"],
["server", "<https URL of moq-relay>", "<https URL of moq-auth>"],
...
],
"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 (`<kind>:<pubkey>:<d>`) when referencing room messages.
@@ -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
}
@@ -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", "<https URL>"]` 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>): String? {
ensure(tag.has(1)) { return null }
@@ -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>): String? {
ensure(tag.has(1)) { return null }
@@ -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", "<https URL>"]` 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>): String? {
ensure(tag.has(1)) { return null }
@@ -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", <unix>]` 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
}
@@ -29,36 +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.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 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.
* 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(
@@ -69,13 +86,18 @@ class NestsServersEvent(
content: String,
sig: HexKey,
) : BaseReplaceableEvent(id, pubKey, createdAt, KIND, tags, content, sig) {
fun servers(): List<String> =
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<NestsServer> =
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 {
@@ -88,37 +110,71 @@ class NestsServersEvent(
fun createAddressTag(pubKey: HexKey): String = Address.assemble(KIND, pubKey, FIXED_D_TAG)
fun createTagArray(servers: List<String>): Array<Array<String>> =
fun createTagArray(servers: List<NestsServer>): Array<Array<String>> =
servers
.map { arrayOf("server", it) }
.map { arrayOf("server", it.relay, it.auth) }
.plusElement(AltTag.assemble(ALT))
.toTypedArray()
suspend fun updateRelayList(
earlierVersion: NestsServersEvent,
servers: List<String>,
servers: List<NestsServer>,
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<String>,
servers: List<NestsServer>,
signer: NostrSigner,
createdAt: Long = TimeUtils.now(),
) = create(relays, signer, createdAt)
) = create(servers, signer, createdAt)
suspend fun create(
servers: List<String>,
servers: List<NestsServer>,
signer: NostrSigner,
createdAt: Long = TimeUtils.now(),
) = signer.sign<NestsServersEvent>(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
* `<scheme>://<host>[: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"
}
}
}
@@ -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",
@@ -52,10 +52,10 @@ 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("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("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))
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))
@@ -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,