feat(nests): adopt deployed nostrnests schema (streaming/auth/live + paired 10112)

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<NestsServer> 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.
This commit is contained in:
Claude
2026-04-29 19:32:53 +00:00
parent 63555db48a
commit aadb347b84
30 changed files with 855 additions and 244 deletions
@@ -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,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,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<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 {
@@ -92,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",
@@ -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))