Merge pull request #3906 from vitorpamplona/claude/relay-auth-group-coverage-o64zcn

Auth: recognize NIP-29 groups and Concord communities as joined venues
This commit is contained in:
Vitor Pamplona
2026-08-12 18:58:22 -04:00
committed by GitHub
16 changed files with 681 additions and 51 deletions
@@ -147,6 +147,7 @@ import com.vitorpamplona.amethyst.service.location.LocationState
import com.vitorpamplona.amethyst.service.relayClient.authCommand.model.InMemoryRelayAuthPermissionStore
import com.vitorpamplona.amethyst.service.relayClient.authCommand.model.RelayAuthPermissionCache
import com.vitorpamplona.amethyst.service.relayClient.authCommand.model.RelayAuthPermissionLedger
import com.vitorpamplona.amethyst.service.relayClient.authCommand.model.RelayAuthVenues
import com.vitorpamplona.amethyst.service.relayClient.chatDelivery.ChatDeliveryTracker
import com.vitorpamplona.amethyst.service.relayClient.notifyCommand.model.NotifyRequestsCache
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.nwc.NWCPaymentFilterAssembler
@@ -424,11 +425,47 @@ class Account(
isInMyRelayList = { relayUrl -> relayUrl.normalizeRelayUrlOrNull()?.let { it in trustedRelays.flow.value } ?: false },
isBlocked = { relayUrl -> relayUrl.normalizeRelayUrlOrNull()?.let { it in blockedRelayList.flow.value } ?: false },
isFollowed = { pubkey -> pubkey in allFollows.flow.value.authors },
isTrustedVenue = { venueId ->
isTrustedVenue = { relayUrl, venueId ->
venueId in publicChatList.flowSet.value ||
venueId in communityList.flowSet.value ||
isJoinedRoomId(relayUrl, venueId) ||
Address.parse(venueId)?.pubKeyHex?.let { it in allFollows.flow.value.authors } == true
},
isVenueHostRelay = { relayUrl -> relayUrl.normalizeRelayUrlOrNull()?.let { it in venueHostRelays() } ?: false },
)
/**
* Relays that exist here because *this account* joined a room on them: the host of every NIP-29
* relay group on its kind-10009 list, plus the relays of every Concord community on its
* kind-13302 list.
*
* Both are venues in the [RelayAuthCustomToggles.myRelaysAndVenues] sense but neither shows up in
* a NIP-65/DM/search list, so nothing else in the auth path can see them: a NIP-29 group's content
* is `#h`-scoped and never names the user, and a Concord plane is addressed to a derived stream
* key rather than to anyone's pubkey.
*/
fun venueHostRelays(): Set<NormalizedRelayUrl> =
RelayAuthVenues.hostRelays(
joinedGroups = relayGroupList.liveRelayGroupIds.value,
joinedCommunities = concordChannelList.liveCommunities.value,
)
/**
* True when [venueId], served by [relayUrl], is a room this account joined that the venue *lists*
* above don't cover: a NIP-29 group id (from the kind-10009 list) or a Concord community id (from
* the kind-13302 list). Those are the ids the subscription assemblers declare on their filters, so
* this is what turns a `READ_VENUE`/`POST_VENUE` on a joined group or community into a trusted
* venue.
*/
private fun isJoinedRoomId(
relayUrl: String,
venueId: String,
): Boolean =
RelayAuthVenues.isJoinedRoom(
venueId = venueId,
relayUrl = relayUrl.normalizeRelayUrlOrNull(),
joinedGroups = relayGroupList.liveRelayGroupIds.value,
joinedCommunities = concordChannelList.liveCommunities.value,
)
// Per-account relay NOTIFY (payment-prompt) cache. NotifyCoordinator attributes each incoming
@@ -846,6 +846,15 @@ object LocalCache : ILocalCache, ICacheProvider, Dao {
fun getOrCreateConcordChannel(key: ConcordChannelId): ConcordChannel = concordChannels.getOrCreate(key) { ConcordChannel(key) }
/**
* Any known channel of Concord community [communityId], or null when we hold none.
*
* A community is not itself a cached object — it has no metadata event, only a folded Control
* Plane — so its display fields (`communityName`, icon, relays) are carried on every one of its
* channels. Callers that need to *name* a community therefore ask for whichever channel we have.
*/
fun getAnyConcordChannelOfCommunity(communityId: HexKey): ConcordChannel? = concordChannels.filter { key, _ -> key.communityId == communityId }.firstOrNull()
/**
* Lands a decrypted Concord chat rumor in the cache as a real Note and, for
* message-like kinds, attaches it to its channel so the shared chat feed and
@@ -82,7 +82,9 @@ import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.RelayIconFilter
import com.vitorpamplona.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.displayUrl
import com.vitorpamplona.quartz.nip29RelayGroups.GroupId
/** Avatars shown in the facepile before the "+N" overflow badge. */
private const val FACEPILE_MAX = 5
@@ -177,7 +179,7 @@ private fun RelayAuthPromptDialog(
val who =
when (primary?.kind) {
AuthPurposeKind.POST_VENUE, AuthPurposeKind.READ_VENUE ->
primary.venues.firstOrNull()?.let { rememberVenueLabel(it, primary.kind, accountViewModel) }
primary.venues.firstOrNull()?.let { rememberVenueLabel(it, primary.kind, prompt.relayUrl, accountViewModel) }
else -> counterpartyLabel(faces, accountViewModel)
}
@@ -478,33 +480,48 @@ private fun rememberCounterpartyName(
}
/**
* A display name for a venue id — a public chat channel (64-hex event id), a NIP-53 live activity,
* or a NIP-72 community.
* A display name for a venue id — a public chat channel (64-hex event id), a NIP-53 live activity, a
* NIP-72 community, a NIP-29 relay group, or a Concord community.
*
* Only a [AuthPurposeKind.POST_VENUE] id is *known* to be a channel (it is the root of a channel
* message we are sending). A READ id may have come from the tag-shape fallback, where a bare `#e`
* list is as likely to be note ids on a thread as channel roots — so we only ever *look up* an
* existing channel there. Get-or-creating on read is what used to mint phantom public chats in
* [LocalCache] for ordinary notes, complete with a metadata subscription for a room that never was.
*
* Both joined-room shapes are resolved **before** any of that, because the rules above would not just
* mislabel them, they would act on them. A NIP-29 group id is only meaningful together with its host
* relay ([relayUrl], the relay doing the asking). A Concord community id is a bare 64-hex string, so
* the public-chat branch would take it: on a `POST_VENUE` — which is exactly what a pending plane wrap
* now derives — that get-or-create mints the phantom channel this function was rewritten to stop
* minting, and then names the room after its own nevent.
*/
@Composable
private fun rememberVenueLabel(
venueId: String,
kind: AuthPurposeKind,
relayUrl: NormalizedRelayUrl,
accountViewModel: AccountViewModel,
): String {
// Resolved off LocalCache first and the active account's list second: a prompt is raised per
// account, so the account on screen is not necessarily the one being asked about — the cache is
// shared by all of them, the list is not.
val concordName = remember(venueId) { concordCommunityLabel(venueId, accountViewModel) }
if (concordName != null) return concordName
val channel: Channel? =
remember(venueId, kind) {
when {
venueId.length == 64 ->
if (kind == AuthPurposeKind.POST_VENUE) {
accountViewModel.checkGetOrCreatePublicChatChannel(venueId)
} else {
LocalCache.getPublicChatChannelIfExists(venueId)
}
venueId.startsWith("30311:") -> Address.parse(venueId)?.let { accountViewModel.checkGetOrCreateLiveActivityChannel(it) }
else -> null
}
remember(venueId, kind, relayUrl) {
LocalCache.getRelayGroupChannelIfExists(GroupId(venueId, relayUrl))
?: when {
venueId.length == 64 ->
if (kind == AuthPurposeKind.POST_VENUE) {
accountViewModel.checkGetOrCreatePublicChatChannel(venueId)
} else {
LocalCache.getPublicChatChannelIfExists(venueId)
}
venueId.startsWith("30311:") -> Address.parse(venueId)?.let { accountViewModel.checkGetOrCreateLiveActivityChannel(it) }
else -> null
}
}
if (channel != null) {
@@ -518,6 +535,30 @@ private fun rememberVenueLabel(
return venueId.substringAfterLast(':').ifEmpty { venueId.take(8) }
}
/**
* The label for a Concord community id, or null when [venueId] is not a community we know of — which
* is what tells the caller to go on treating the id as a channel root.
*
* A community has no metadata event to look up: its name comes from the folded Control Plane, held on
* the `ConcordChannel`s in [LocalCache] (shared by every logged-in account, so this still resolves a
* prompt raised for a different one), and failing that from the active account's joined list. A
* community we can place but cannot name still returns a label, so the caller never falls through to
* the channel branches with an id it would get-or-create.
*/
private fun concordCommunityLabel(
venueId: String,
accountViewModel: AccountViewModel,
): String? {
val folded = LocalCache.getAnyConcordChannelOfCommunity(venueId)
val joined =
accountViewModel.account.concordChannelList.liveCommunities.value
.firstOrNull { it.id == venueId }
if (folded == null && joined == null) return null
return folded?.communityName?.takeIf { it.isNotBlank() }
?: joined?.name?.takeIf { it.isNotBlank() }
?: venueId.take(8)
}
/**
* The best display name for [pubkey], reactive to metadata arriving from relays. Falls back to a
* generic "someone you haven't loaded yet" rather than a hex prefix dressed up as a person's name.
@@ -76,6 +76,10 @@ class AuthCoordinator(
RelayAuthPurposeDeriver.derive(
pendingEvents = client.activeOutboxEvents(relayUrl),
activeFilters = client.activeRequests(relayUrl),
// The context describes the shared socket, not one account, so a
// plane is looked up across every watched account — same as the
// stream-key AUTHs above.
venueForPlaneAuthor = ::concordCommunityForPlane,
),
)
}
@@ -165,6 +169,13 @@ class AuthCoordinator(
},
)
/**
* The joined Concord community whose plane [planeAddress] is, across every watched account, or
* null when the pubkey isn't a plane of ours. Feeds [RelayAuthPurposeDeriver] so a pending plane
* wrap reads as a post into that community rather than as a DM to the throwaway key it `p`-tags.
*/
private fun concordCommunityForPlane(planeAddress: HexKey): HexKey? = authWithAccounts.distinct().firstNotNullOfOrNull { it.concordSessions.communityIdForPlane(planeAddress) }
/**
* Signs one kind-22242 AUTH per Concord plane stream key hosted on [relayUrl], across every
* watched account. Signed locally from the derived stream secret (a raw [KeyPair] via
@@ -237,13 +248,12 @@ class AuthCoordinator(
relayUrl = relayUrl,
pendingEvents = client.activeOutboxEvents(relayUrl),
myRelays = account.trustedRelays.flow.value,
// A NIP-29 relay group the user explicitly joined (kind-10009) is a first-party reason
// to authenticate with its host relay: private/closed group content is `#h`-scoped and
// never names the user, so it fails the pubkey checks above — without this, a joined
// private group's messages are refused with `auth-required` and the group stays empty.
myGroupRelays =
account.relayGroupList.liveRelayGroupIds.value
.mapTo(mutableSetOf()) { it.relayUrl },
// A room the user explicitly joined — a NIP-29 relay group (kind-10009) or a Concord
// community (kind-13302) — makes its host relay first-party. Neither room's traffic
// names the user: NIP-29 content is `#h`-scoped and Concord planes ride derived stream
// keys, so both fail the pubkey checks above. Without this, a joined private group's
// messages are refused with `auth-required` and the room stays empty.
myVenueRelays = account.venueHostRelays(),
)
fun destroy() {
@@ -35,13 +35,15 @@ import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
* the recipient's inbox relay), or
* - the relay is one it configured itself ([myRelays] — its NIP-65 / DM / search / … lists, which
* is where its own inbox/outbox reads are routed anyway), or
* - the relay hosts a NIP-29 relay group the account explicitly joined ([myGroupRelays], from its
* kind-10009 list). A private/closed group's content (kind-9 chat, kind-11 threads, …) is
* `#h`-scoped and served only to authenticated members; that read never names the user, so it
* can't qualify via [pendingEvents], and a group host relay is not one of the account's own
* NIP-65/DM/… lists, so it can't qualify via [myRelays] either. Without this, a joined private
* group is refused with `auth-required` and renders empty — the group's own metadata (39000) is
* public and still loads, so the group appears but shows no messages.
* - the relay hosts a room the account explicitly joined ([myVenueRelays]): a NIP-29 relay group
* from its kind-10009 list, or a Concord community from its kind-13302 list. Neither kind of room
* can qualify by the two rules above. A private/closed NIP-29 group's content (kind-9 chat,
* kind-11 threads, …) is `#h`-scoped and served only to authenticated members, so the read never
* names the user; a Concord plane is authored by — and addressed to — a derived stream key, so
* even the wraps the account *publishes* there carry someone else's pubkey. And neither host relay
* is in the account's own NIP-65/DM/… lists, so [myRelays] doesn't see them either. Without this,
* a joined private group is refused with `auth-required` and renders empty — the group's own
* metadata (39000) is public and still loads, so the group appears but shows no messages.
*
* Crucially, an active subscription merely *naming* the account (a `#p` tag or `authors` entry) is
* NOT a first-party reason: the app packs several accounts' pubkeys into one merged filter and fans
@@ -56,10 +58,10 @@ object RelayAuthFirstParty {
relayUrl: NormalizedRelayUrl,
pendingEvents: List<Event>,
myRelays: Set<NormalizedRelayUrl>,
myGroupRelays: Set<NormalizedRelayUrl> = emptySet(),
myVenueRelays: Set<NormalizedRelayUrl> = emptySet(),
): Boolean {
if (pendingEvents.any { it.pubKey == me }) return true
if (relayUrl in myRelays) return true
return relayUrl in myGroupRelays
return relayUrl in myVenueRelays
}
}
@@ -37,6 +37,14 @@ import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthVerdict
* [globalPolicy] → prompt-if-attributable-else-deny. Under [RelayAuthPolicy.CUSTOM] the
* [customToggles] gate each category, using [isFollowed] to split the counterparties carried in the
* [RelayAuthContext] into followed vs. stranger.
*
* Venues come in two shapes and both count as "a room I joined" for the
* [RelayAuthCustomToggles.myRelaysAndVenues] toggle: [isTrustedVenue] recognizes a venue *id* named
* by a purpose (a NIP-28 chat, a NIP-72 community, a NIP-53 stream, a NIP-29 group id, a Concord
* community id), while [isVenueHostRelay] recognizes the relay that *hosts* one — the only signal
* available for a room whose traffic never names it in a way the deriver can see, or whose challenge
* arrives before its subscription does. [isTrustedVenue] is asked about the (relay, venue) pair
* rather than the id alone because a NIP-29 group id means nothing without its host relay.
*/
class RelayAuthPermissionLedger(
val store: RelayAuthPermissionStore,
@@ -45,7 +53,8 @@ class RelayAuthPermissionLedger(
val isInMyRelayList: (String) -> Boolean = { false },
val isBlocked: (String) -> Boolean = { false },
val isFollowed: (String) -> Boolean = { false },
val isTrustedVenue: (String) -> Boolean = { false },
val isTrustedVenue: (relayUrl: String, venueId: String) -> Boolean = { _, _ -> false },
val isVenueHostRelay: (String) -> Boolean = { false },
) {
/**
* The authorization verdict for [ctx], taking the challenge's purpose into account.
@@ -59,6 +68,15 @@ class RelayAuthPermissionLedger(
isFirstParty: Boolean = true,
): RelayAuthVerdict {
fun isWrite(kind: AuthPurposeKind) = kind == AuthPurposeKind.SEND_DM || kind == AuthPurposeKind.NOTIFY_INBOX
// The relay itself hosts a room this account joined — a NIP-29 relay group or a Concord
// community. Computed apart from the purposes because those rooms are the relay's whole
// reason to be here: their traffic is `#h`-scoped (NIP-29) or addressed to derived stream
// keys (Concord), so an AUTH challenge that lands before the room's subscription is
// assembled carries no venue to match — and the joined room would sit empty behind a
// question the user already answered by joining it.
val hostsMyVenue = isVenueHostRelay(ctx.relayUrl)
val inputs =
RelayAuthInputs(
storedOverride = store.loadDecision(ctx.relayUrl),
@@ -67,10 +85,11 @@ class RelayAuthPermissionLedger(
toggles = customToggles(),
isInMyRelayList = isInMyRelayList(ctx.relayUrl),
servesTrustedVenue =
ctx.purposes.any { p ->
(p.kind == AuthPurposeKind.POST_VENUE || p.kind == AuthPurposeKind.READ_VENUE) &&
p.venues.any(isTrustedVenue)
},
hostsMyVenue ||
ctx.purposes.any { p ->
(p.kind == AuthPurposeKind.POST_VENUE || p.kind == AuthPurposeKind.READ_VENUE) &&
p.venues.any { isTrustedVenue(ctx.relayUrl, it) }
},
// Reading a followed author's outbox.
servesFollowedReadCounterparty =
ctx.purposes.any { p ->
@@ -82,18 +101,22 @@ class RelayAuthPermissionLedger(
// Messaging a non-followed user's inbox.
servesStrangerWriteCounterparty =
ctx.purposes.any { p -> isWrite(p.kind) && p.counterparties.any { !isFollowed(it) } },
// Hosting a joined room is itself an explanation, and the only one available when the
// challenge arrives before any of that room's filters do. Without it, a user on
// "decide per relay" (or with the venue toggle off) would get a silent denial for a
// room they joined instead of the question. MY_INBOX and THREAD likewise name no
// counterparty by design — the relay is holding back the user's *own* inbox or the
// conversation on screen — and are fully explainable, so they too must reach ASK.
hasAttributablePurpose =
ctx.purposes.any {
// MY_INBOX and THREAD name no counterparty by design — the relay is holding
// back the user's *own* inbox or the conversation on screen. They are still
// fully explainable, so they must reach ASK rather than a silent DENY.
it.kind == AuthPurposeKind.MY_OWN_RELAY ||
it.kind == AuthPurposeKind.OTHER ||
it.kind == AuthPurposeKind.MY_INBOX ||
it.kind == AuthPurposeKind.THREAD ||
it.counterparties.isNotEmpty() ||
it.venues.isNotEmpty()
},
hostsMyVenue ||
ctx.purposes.any {
it.kind == AuthPurposeKind.MY_OWN_RELAY ||
it.kind == AuthPurposeKind.OTHER ||
it.kind == AuthPurposeKind.MY_INBOX ||
it.kind == AuthPurposeKind.THREAD ||
it.counterparties.isNotEmpty() ||
it.venues.isNotEmpty()
},
isFirstParty = isFirstParty,
)
return RelayAuthResolver.resolve(inputs)
@@ -24,6 +24,9 @@ import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFil
import com.vitorpamplona.amethyst.commons.relayauth.AuthPurpose
import com.vitorpamplona.amethyst.commons.relayauth.AuthPurposeKind
import com.vitorpamplona.amethyst.commons.relayauth.toAuthPurposeKind
import com.vitorpamplona.quartz.concord.envelope.ConcordStreamEnvelope
import com.vitorpamplona.quartz.marmot.mip02Welcome.WelcomeEvent
import com.vitorpamplona.quartz.marmot.mip03GroupMessages.GroupEvent
import com.vitorpamplona.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
@@ -33,6 +36,7 @@ import com.vitorpamplona.quartz.nip01Core.tags.events.ETag
import com.vitorpamplona.quartz.nip01Core.tags.people.PTag
import com.vitorpamplona.quartz.nip10Notes.tags.MarkedETag
import com.vitorpamplona.quartz.nip28PublicChat.message.ChannelMessageEvent
import com.vitorpamplona.quartz.nip29RelayGroups.tags.GroupIdTag
import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent
import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent
import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent
@@ -41,6 +45,18 @@ import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefiniti
* activities. Their `a` addresses are `kind:ownerPubkey:dTag`. */
private val VENUE_KINDS = setOf(CommunityDefinitionEvent.KIND, LiveActivitiesEvent.KIND)
/** The kinds a Concord plane wrap can be — the only ones worth asking `venueForPlaneAuthor` about. */
private val STREAM_WRAP_KINDS = setOf(ConcordStreamEnvelope.KIND_WRAP, ConcordStreamEnvelope.KIND_WRAP_EPHEMERAL)
/**
* Marmot (MLS) carries the group id in an `h` tag exactly like NIP-29 does, but an MLS group is not a
* room we can name: the id is an opaque MLS value with no metadata event and no channel object behind
* it. Reading it as a venue would put that id in front of the user as a room name — and, on a
* `POST_VENUE`, get-or-create a phantom public chat for it (the id is 64-hex). These keep their prior
* reading: no `p` tags, so they land on the unattributed safety net.
*/
private val MLS_GROUP_KINDS = setOf(GroupEvent.KIND, WelcomeEvent.KIND)
/**
* Infers *why* a relay wants NIP-42 auth from what Amethyst is currently doing with it — the
* events pending delivery and the active subscription filters (both from the [INostrClient]).
@@ -48,10 +64,19 @@ private val VENUE_KINDS = setOf(CommunityDefinitionEvent.KIND, LiveActivitiesEve
*
* Sends (from the outbox) are read from the events themselves:
*
* - a pending Concord plane wrap (recognized by [venueForPlaneAuthor], since the wrap is *signed* by
* the plane's stream key) => [AuthPurposeKind.POST_VENUE] for that community;
* - a pending `h`-tagged event => [AuthPurposeKind.POST_VENUE] for that NIP-29 relay group (except
* the [MLS_GROUP_KINDS], whose `h` names something unnameable);
* - a pending gift wrap (kind 1059) => sending a DM to its `p` recipient ([AuthPurposeKind.SEND_DM]);
* - a pending channel/community/live post => [AuthPurposeKind.POST_VENUE] for that venue;
* - any other pending event with `p` tags => delivering it to those users' inboxes ([AuthPurposeKind.NOTIFY_INBOX]).
*
* The two room rules come first because both would otherwise be read as something else entirely: a
* Concord wrap is kind 1059 `p`-tagged to a *throwaway* pubkey, so the gift-wrap rule would explain a
* community post as a DM to a stranger nobody can name, and a NIP-29 chat message mentioning someone
* would be explained as a notification rather than as a message into the group.
*
* Reads prefer the purpose the subscription **declared**. Assemblers build every filter as an
* [ExplainedFilter] carrying a [SubPurpose][com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose]
* and the entity ids it serves, so we simply read it (see `toAuthPurposeKind`). That is the only way
@@ -66,9 +91,14 @@ private val VENUE_KINDS = setOf(CommunityDefinitionEvent.KIND, LiveActivitiesEve
* is prompted about instead of silently failing.
*/
object RelayAuthPurposeDeriver {
/**
* @param venueForPlaneAuthor maps the pubkey that *signed* a pending event to the room it streams
* for — today, a Concord plane address to its community id. Returns null for anything else.
*/
fun derive(
pendingEvents: List<Event>,
activeFilters: Map<String, List<Filter>>,
venueForPlaneAuthor: (HexKey) -> String? = { null },
): List<AuthPurpose> {
val dmRecipients = mutableSetOf<HexKey>()
val notifyRecipients = mutableSetOf<HexKey>()
@@ -78,7 +108,12 @@ object RelayAuthPurposeDeriver {
pendingEvents.forEach { event ->
val pubkeys = event.tags.mapNotNull(PTag::parseKey)
val venues = event.tags.mapNotNull(ATag::parseAddress).filter { it.kind in VENUE_KINDS }
// Only a stream wrap can belong to a plane, so only a wrap pays for the lookup.
val planeVenue = if (event.kind in STREAM_WRAP_KINDS) venueForPlaneAuthor(event.pubKey) else null
val groupId = if (event.kind in MLS_GROUP_KINDS) null else event.tags.firstNotNullOfOrNull(GroupIdTag::parse)
when {
planeVenue != null -> postVenues.add(planeVenue)
groupId != null -> postVenues.add(groupId)
event.kind == GiftWrapEvent.KIND -> dmRecipients.addAll(pubkeys)
event.kind == ChannelMessageEvent.KIND -> event.tags.channelRootId()?.let(postVenues::add)
venues.isNotEmpty() -> venues.forEach { postVenues.add(it.toValue()) }
@@ -0,0 +1,81 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.service.relayClient.authCommand.model
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEntry
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
import com.vitorpamplona.quartz.nip29RelayGroups.GroupId
/**
* The rooms an account joined that the NIP-51 venue lists don't describe: NIP-29 relay groups (the
* kind-10009 list) and Concord communities (the kind-13302 list).
*
* Both are venues in the "…it's my relay, or a room I joined" sense, and both are invisible to every
* other signal in the auth path — a NIP-29 group's content is `#h`-scoped and never names the user,
* and a Concord plane is authored by and addressed to derived stream keys — so this is what feeds
* them to [RelayAuthPermissionLedger] and [RelayAuthFirstParty]. Pure, so the id/url matching is
* testable without an [com.vitorpamplona.amethyst.model.Account].
*
* Everything here is per account, deliberately: these lists come off *this* account's own list events,
* so one account's rooms can never grant another account's identity away. That rules out the
* process-wide joined sets (Buzz workspaces) — they carry no account, so folding them in here would
* auto-authenticate every logged-in account on a workspace only one of them joined.
*/
object RelayAuthVenues {
/**
* The relays these rooms live on. A NIP-29 group id is only meaningful together with its host, so
* the [GroupId] carries an already-normalized url; a Concord entry stores raw url strings written
* by whoever created the community, so those are normalized here — comparing them verbatim
* against a challenge's relay url is what makes a trailing slash or a `wss://` case difference
* quietly drop the whole community.
*/
fun hostRelays(
joinedGroups: Set<GroupId>,
joinedCommunities: List<ConcordCommunityListEntry>,
): Set<NormalizedRelayUrl> =
buildSet {
joinedGroups.mapTo(this) { it.relayUrl }
joinedCommunities.forEach { entry ->
entry.relays.forEach { url -> RelayUrlNormalizer.normalizeOrNull(url)?.let(::add) }
}
}
/**
* True when [venueId], as served by [relayUrl], names one of these rooms. These are the ids the
* subscription assemblers declare as their filters' entity ids, so this is what turns a
* `READ_VENUE`/`POST_VENUE` on a joined room into a trusted venue.
*
* A NIP-29 group id is matched **together with its host**: ids are scoped to their relay and are
* routinely generic (`_` is the spec's relay-wide group), so an id alone would let a group we
* merely browsed on some other relay pass for one we joined. A Concord community id is a 64-hex
* derived value that names exactly one community wherever it is served, so it matches on its own
* — and its relays are covered by [hostRelays] anyway.
*/
fun isJoinedRoom(
venueId: String,
relayUrl: NormalizedRelayUrl?,
joinedGroups: Set<GroupId>,
joinedCommunities: List<ConcordCommunityListEntry>,
): Boolean =
(relayUrl != null && GroupId(venueId, relayUrl) in joinedGroups) ||
joinedCommunities.any { it.id == venueId }
}
@@ -88,4 +88,14 @@ class RelayAuthFirstPartyTest {
val groupRelay = NormalizedRelayUrl("wss://chat.wisp.talk/")
assertFalse(RelayAuthFirstParty.hasReason(me, groupRelay, emptyList(), emptySet(), emptySet()))
}
@Test
fun aRelayHostingAJoinedConcordCommunityIsFirstParty() {
// Concord is the harder case of the same rule: a plane wrap this account publishes is signed
// by the plane's *stream key*, so even its own outbound traffic carries someone else's pubkey
// and the pendingEvents rule can never fire. The joined-communities list is the only signal.
val concordRelay = NormalizedRelayUrl("wss://relay.dreamith.to/")
val planeWrap = event(other)
assertTrue(RelayAuthFirstParty.hasReason(me, concordRelay, listOf(planeWrap), emptySet(), setOf(concordRelay)))
}
}
@@ -229,6 +229,114 @@ class RelayAuthPurposeDeriverTest {
assertEquals(setOf(alice), purposes[0].counterparties)
}
// ---- rooms whose traffic doesn't look like a room -----------------------------------------
@Test
fun postingIntoARelayGroupIsAVenuePostAndNotANotification() {
// A NIP-29 chat message is `#h`-scoped; the mention it carries would otherwise make this read
// as "delivering a notification to alice" instead of "posting into the group".
val groupId = "abcd1234"
val ev =
Event(
id = "00".repeat(32),
pubKey = "11".repeat(32),
createdAt = 1_700_000_000L,
kind = 9,
tags = arrayOf(arrayOf("h", groupId), arrayOf("p", alice)),
content = "hi",
sig = "22".repeat(64),
)
val purposes = RelayAuthPurposeDeriver.derive(listOf(ev), emptyMap())
assertEquals(listOf(AuthPurposeKind.POST_VENUE), purposes.map { it.kind })
assertEquals(setOf(groupId), purposes[0].venues)
}
@Test
fun aMarmotGroupMessageIsNotTreatedAsARoomWeCanName() {
// MLS carries its group id in an `h` tag exactly like NIP-29, but the id is an opaque MLS
// value with no metadata event behind it — and it is 64-hex, so a POST_VENUE on it would have
// the label get-or-create a phantom public chat. Stays on the unattributed safety net.
val ev =
Event(
id = "00".repeat(32),
pubKey = "11".repeat(32),
createdAt = 1_700_000_000L,
kind = 445,
tags = arrayOf(arrayOf("h", "d".repeat(64))),
content = "",
sig = "22".repeat(64),
)
val purposes = RelayAuthPurposeDeriver.derive(listOf(ev), emptyMap())
assertEquals(listOf(AuthPurposeKind.OTHER), purposes.map { it.kind })
}
@Test
fun aPendingEventThatIsNotAWrapIsNeverAskedAboutPlanes() {
// The plane lookup walks every joined community on every pending event; only a stream wrap
// can belong to a plane, so only a wrap may pay for it.
var asked = 0
RelayAuthPurposeDeriver.derive(
pendingEvents = listOf(event(1, listOf(alice)), event(GiftWrapEvent.KIND, listOf(bob))),
activeFilters = emptyMap(),
venueForPlaneAuthor = {
asked++
null
},
)
assertEquals(1, asked)
}
@Test
fun postingIntoAConcordChannelIsAVenuePostAndNotADmToItsThrowawayPTag() {
// A Concord plane wrap is kind 1059 signed by the plane's stream key and `p`-tagged to a fresh
// random pubkey. On tag shape alone it is a gift wrap, so the prompt used to offer to "send a
// message" to a key that belongs to nobody and will never be seen again.
val planeAddress = "9".repeat(64)
val communityId = "c".repeat(64)
val throwaway = "e".repeat(64)
val wrap =
Event(
id = "00".repeat(32),
pubKey = planeAddress,
createdAt = 1_700_000_000L,
kind = GiftWrapEvent.KIND,
tags = arrayOf(arrayOf("p", throwaway)),
content = "",
sig = "22".repeat(64),
)
val purposes =
RelayAuthPurposeDeriver.derive(
pendingEvents = listOf(wrap),
activeFilters = emptyMap(),
venueForPlaneAuthor = { if (it == planeAddress) communityId else null },
)
assertEquals(listOf(AuthPurposeKind.POST_VENUE), purposes.map { it.kind })
assertEquals(setOf(communityId), purposes[0].venues)
assertEquals(emptySet<String>(), purposes[0].counterparties)
}
@Test
fun aGiftWrapFromAnUnknownAuthorIsStillADm() {
// The plane lookup must not swallow real NIP-17 traffic: an author we don't recognize as a
// plane keeps the gift-wrap reading.
val purposes =
RelayAuthPurposeDeriver.derive(
pendingEvents = listOf(event(GiftWrapEvent.KIND, listOf(alice))),
activeFilters = emptyMap(),
venueForPlaneAuthor = { null },
)
assertEquals(listOf(AuthPurposeKind.SEND_DM), purposes.map { it.kind })
assertEquals(setOf(alice), purposes[0].counterparties)
}
@Test
fun readingMyInboxAndSendingADmAreBothReported() {
val purposes =
@@ -0,0 +1,125 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.service.relayClient.authCommand.model
import com.vitorpamplona.amethyst.commons.relayauth.AuthPurpose
import com.vitorpamplona.amethyst.commons.relayauth.AuthPurposeKind
import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthContext
import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthCustomToggles
import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthDecision
import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthPermissionStore
import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthPolicy
import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthVerdict
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Test
/**
* "…it's my relay, or a room I joined" must actually cover every room the app lets you join. NIP-29
* relay groups and Concord communities were invisible to it: neither id is on the public-chat or
* NIP-72 community lists the venue check consulted, so a relay whose only job is hosting a group the
* user joined fell through to a prompt on every connection (or, with nothing attributable yet, to a
* silent denial that left the room empty).
*/
class RelayAuthVenueCoverageTest {
private val groupRelay = "wss://groups.example.com/"
private val concordRelay = "wss://relay.dreamith.to/"
private val joinedGroupId = "abcd1234"
private val joinedCommunityId = "c".repeat(64)
private class NoStore : RelayAuthPermissionStore {
override suspend fun loadDecision(relayUrl: String): RelayAuthDecision? = null
override suspend fun storeDecision(
relayUrl: String,
decision: RelayAuthDecision,
) = Unit
override suspend fun clearDecision(relayUrl: String) = Unit
override suspend fun allDecisions(): Map<String, RelayAuthDecision> = emptyMap()
}
private fun ledger(toggles: RelayAuthCustomToggles = RelayAuthCustomToggles()) =
RelayAuthPermissionLedger(
store = NoStore(),
globalPolicy = { RelayAuthPolicy.CUSTOM },
customToggles = { toggles },
isTrustedVenue = { _, venueId -> venueId == joinedGroupId || venueId == joinedCommunityId },
isVenueHostRelay = { it == groupRelay || it == concordRelay },
)
private fun readVenue(
relayUrl: String,
venueId: String,
) = RelayAuthContext(relayUrl, listOf(AuthPurpose(AuthPurposeKind.READ_VENUE, venues = setOf(venueId))))
@Test
fun readingAJoinedRelayGroupAutoAuthenticates() =
runTest {
// The group's chat is `#h`-scoped: the subscription declares RELAY_GROUPS, which carries
// the group id as its venue. That id is on the kind-10009 list, so the venue toggle covers
// it exactly like a public chat the user joined.
assertEquals(RelayAuthVerdict.ALLOW, ledger().decide(readVenue(groupRelay, joinedGroupId)))
}
@Test
fun readingAJoinedConcordCommunityAutoAuthenticates() =
runTest {
assertEquals(RelayAuthVerdict.ALLOW, ledger().decide(readVenue(concordRelay, joinedCommunityId)))
}
@Test
fun aJoinedRoomsHostAutoAuthenticatesBeforeAnyOfItsFiltersExist() =
runTest {
// The challenge that matters arrives on connect, before the room's subscription is
// assembled — so there is no venue in the context to match. The host relay itself is the
// signal: nothing else is on it.
assertEquals(RelayAuthVerdict.ALLOW, ledger().decide(RelayAuthContext(groupRelay)))
}
@Test
fun aJoinedRoomsHostStillAsksWhenTheVenueToggleIsOff() =
runTest {
// Turning the toggle off must mean "ask me", not "deny in silence": hosting a room the user
// joined is an explanation, so the challenge is attributable even with no purposes.
val verdict = ledger(RelayAuthCustomToggles(myRelaysAndVenues = false)).decide(RelayAuthContext(groupRelay))
assertEquals(RelayAuthVerdict.ASK, verdict)
}
@Test
fun aGroupOnARelayIHaveNotJoinedIsNotAutoAuthenticated() =
runTest {
// Browsing a group directory names a venue we never joined on a relay that hosts nothing of
// ours — explainable, so it asks, but it is never silently granted.
val verdict = ledger().decide(readVenue("wss://stranger.example.com/", "somebodyElsesGroup"))
assertEquals(RelayAuthVerdict.ASK, verdict)
}
@Test
fun aJoinedRoomsHostIsNotAutoAuthenticatedForABystanderAccount() =
runTest {
// The room belongs to another logged-in account; this one has no first-party reason to be
// on the relay, so it asks instead of revealing its npub on its own.
val verdict = ledger().decide(readVenue(groupRelay, joinedGroupId), isFirstParty = false)
assertEquals(RelayAuthVerdict.ASK, verdict)
}
}
@@ -0,0 +1,111 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.service.relayClient.authCommand.model
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEntry
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
import com.vitorpamplona.quartz.nip29RelayGroups.GroupId
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
/** The joined-room sources behind the "…it's my relay, or a room I joined" auto-login toggle. */
class RelayAuthVenuesTest {
private val groupHost = RelayUrlNormalizer.normalize("wss://groups.example.com")
private val communityId = "c".repeat(64)
private val joinedGroups = setOf(GroupId("abcd1234", groupHost))
private fun community(relays: List<String>) =
ConcordCommunityListEntry(
id = communityId,
owner = "1".repeat(64),
ownerSalt = "2".repeat(64),
root = "3".repeat(64),
relays = relays,
name = "Dreamith",
)
@Test
fun bothJoinedRoomKindsContributeTheirHostRelays() {
val relays = RelayAuthVenues.hostRelays(joinedGroups, listOf(community(listOf("wss://relay.dreamith.to"))))
assertEquals(
setOf(groupHost, RelayUrlNormalizer.normalize("wss://relay.dreamith.to")),
relays,
)
}
@Test
fun aConcordEntrysRawRelayStringIsNormalizedBeforeItIsCompared() {
// The community list stores whatever url the community's creator wrote. Comparing that
// verbatim against a normalized challenge url is how a whole community silently stops
// matching over a trailing slash or an upper-case host.
val relays = RelayAuthVenues.hostRelays(emptySet(), listOf(community(listOf("wss://Relay.Dreamith.to/"))))
assertEquals(setOf(RelayUrlNormalizer.normalize("wss://relay.dreamith.to")), relays)
}
@Test
fun anUnparseableRelayIsDroppedRatherThanFailingTheWholeList() {
val relays = RelayAuthVenues.hostRelays(joinedGroups, listOf(community(listOf("not a url at all"))))
assertEquals(setOf(groupHost), relays)
}
@Test
fun aJoinedGroupIdAndCommunityIdAreBothJoinedRooms() {
val communities = listOf(community(listOf("wss://relay.dreamith.to")))
assertTrue(RelayAuthVenues.isJoinedRoom("abcd1234", groupHost, joinedGroups, communities))
assertTrue(RelayAuthVenues.isJoinedRoom(communityId, groupHost, joinedGroups, communities))
}
@Test
fun aRoomIHaveNotJoinedIsNotAJoinedRoom() {
val communities = listOf(community(listOf("wss://relay.dreamith.to")))
assertFalse(RelayAuthVenues.isJoinedRoom("someoneElsesGroup", groupHost, joinedGroups, communities))
assertFalse(RelayAuthVenues.isJoinedRoom("d".repeat(64), groupHost, joinedGroups, communities))
}
@Test
fun aGroupIdOnlyCountsOnTheRelayIJoinedItOn() {
// NIP-29 ids are relay-scoped and often generic — `_` is the spec's relay-wide group — so an
// id-only match would let a group merely browsed on some other relay pass for one I joined,
// and hand that relay an automatic login.
val elsewhere = RelayUrlNormalizer.normalize("wss://other.example.com")
assertFalse(RelayAuthVenues.isJoinedRoom("abcd1234", elsewhere, joinedGroups, emptyList()))
assertFalse(RelayAuthVenues.isJoinedRoom("abcd1234", null, joinedGroups, emptyList()))
}
@Test
fun aCommunityIdCountsWhereverItIsServed() {
// Unlike a group id, a Concord community id is a 64-hex derived value that names exactly one
// community anywhere — and a community's own relays are covered by hostRelays regardless.
val elsewhere = RelayUrlNormalizer.normalize("wss://other.example.com")
val communities = listOf(community(listOf("wss://relay.dreamith.to")))
assertTrue(RelayAuthVenues.isJoinedRoom(communityId, elsewhere, emptySet(), communities))
}
}
@@ -277,6 +277,22 @@ class ConcordCommunitySession(
*/
fun channelAddresses(): Set<HexKey> = lock.withLock { channelKeysByAddress.keys + historicalChannelKeysByAddress.keys }
/**
* True when [address] is one of this community's plane addresses — Control (current or a prior
* epoch we still hold), Guestbook, next-epoch rekey, or any folded Chat Plane.
*
* A membership test rather than a set to iterate, because the caller is the NIP-42 auth path
* asking "whose room is this wrap for?" on every challenge: answering that from
* [channelAddresses] + [historicalControlPlaneAddresses] allocates a fresh union per community
* per question, where four map lookups do.
*/
fun ownsPlane(address: HexKey): Boolean =
address == controlPlaneAddress ||
address == guestbookAddress ||
address == nextBaseRekeyAddress ||
address in historicalControlKeys ||
lock.withLock { address in channelKeysByAddress || address in historicalChannelKeysByAddress }
/** The Chat Plane stream address for [channelIdHex], once this community has folded that channel (else null). */
fun channelPlaneAddress(channelIdHex: HexKey): HexKey? = lock.withLock { channelKeysByAddress.entries.firstOrNull { it.value.first == channelIdHex }?.key }
@@ -157,6 +157,13 @@ class ConcordSessionManager(
return outcome.claimed
}
/**
* The joined community [planeAddress] streams for, or null when it isn't one of ours. Lets the
* NIP-42 auth path name a pending plane wrap as a post into that community instead of reading its
* throwaway `p` tag as a DM recipient (see [ConcordSessionRegistry.communityIdForPlane]).
*/
fun communityIdForPlane(planeAddress: HexKey): HexKey? = registry.communityIdForPlane(planeAddress)
fun sessions() = registry.sessions()
fun sessionFor(communityId: HexKey) = registry.sessionFor(communityId)
@@ -108,6 +108,19 @@ class ConcordSessionRegistry(
out
}
/**
* The joined community whose planes [planeAddress] belongs to, or null when no session claims it.
*
* The NIP-42 auth path reads this to recognize an outbound plane wrap for what it is. A wrap is
* signed by the plane's stream key and `p`-tagged to a throwaway pubkey, so on tag shape alone it
* is indistinguishable from a NIP-17 gift wrap — a post into a community would otherwise be
* explained to the user as a direct message to a stranger nobody can name.
*/
fun communityIdForPlane(planeAddress: HexKey): HexKey? =
lock.withLock {
sessions.entries.firstOrNull { (_, session) -> session.ownsPlane(planeAddress) }?.key
}
/**
* Routes an inbound stream [wrap] to whichever session recognizes it, returning that session's
* [ConcordIngestOutcome] (or [ConcordIngestOutcome.NOT_MINE] if none claim it). A wrap belongs to
@@ -24,8 +24,8 @@ package com.vitorpamplona.amethyst.commons.relayauth
* The per-situation switches applied under [RelayAuthPolicy.CUSTOM]. Each independently authorizes
* one category of relay; a situation with no matching toggle falls through to a prompt.
*
* @param myRelaysAndVenues your own relays, plus venues (public chats, communities, live streams)
* you've joined, subscribed to, or favorited.
* @param myRelaysAndVenues your own relays, plus venues (public chats, NIP-72 communities, live
* streams, NIP-29 relay groups, Concord communities) you've joined, subscribed to, or favorited.
* @param readFollows a relay serving the outbox of someone you follow (to download their posts).
* @param messageFollows a relay serving the inbox of someone you follow (to send DMs, replies,
* notifications).
@@ -49,8 +49,10 @@ data class RelayAuthCustomToggles(
* @param policy the top-level [RelayAuthPolicy].
* @param toggles the [RelayAuthCustomToggles] applied when [policy] is [RelayAuthPolicy.CUSTOM].
* @param isInMyRelayList the relay is in the user's own relay list.
* @param servesTrustedVenue this relay hosts a venue (public chat, community, or live stream) the
* user has joined, subscribed to, or favorited.
* @param servesTrustedVenue this relay hosts a venue the user has joined, subscribed to, or
* favorited — a public chat, a NIP-72 community, a live stream, a NIP-29 relay group, or a Concord
* community. True either because a purpose names one of those venues or because the relay itself is
* a joined room's host (a group/community relay serves nothing else).
* @param servesFollowedReadCounterparty a followed user's outbox is served here (reading them).
* @param servesFollowedWriteCounterparty a followed user's inbox is served here (messaging them).
* @param servesStrangerWriteCounterparty a non-followed user's inbox is served here (messaging them).