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:
+3
-3
@@ -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
|
||||
}
|
||||
|
||||
+11
-6
@@ -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 }
|
||||
|
||||
+11
-5
@@ -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 }
|
||||
|
||||
+27
-11
@@ -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
|
||||
}
|
||||
|
||||
+84
-32
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+4
-4
@@ -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))
|
||||
|
||||
Reference in New Issue
Block a user