feat(relayauth): venue-aware auth for public chats, communities & live streams

The purpose model was person-centric, so an auth-required NIP-28 channel,
NIP-72 community, or NIP-53 live stream broke: reads filter by #e/#a with
no authors, so nothing was attributable -> silent DENY (chat wouldn't
load); top-level posts (no p tags) also silently failed; replies were
mis-attributed as "notify" and trusted on the wrong signal.

Add venue purposes (POST_VENUE / READ_VENUE) carrying the venue id
(channel event-id, or community/live `kind:pubkey:dTag` address). The
deriver recognizes kind-42 channel posts (root `e`), community/live posts
and reads (`#a` 34550/30311), and channel reads (`#e`). Venues are trusted
under TRUSTED_FOLLOWS when you've joined them (publicChatList /
communityList) or their owner — the pubkey in the address, e.g. a live
stream's host — is someone you follow; trusted venues auto-auth for both
reading and posting.

Safety net: any active use we still can't attribute yields an OTHER
purpose so the relay is prompted about instead of failing silently.
Prompt copy gains venue-aware titles/consequences ("Post to this room?",
"If you don't, your message won't be posted."). Default policy already
TRUSTED_FOLLOWS, so joined venues just work. Unit-tested across resolver
and deriver.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EZjmYpgHP4pf79Sav5QT8a
This commit is contained in:
Claude
2026-07-10 22:40:11 +00:00
parent eea072ec42
commit 3526a14ebe
10 changed files with 174 additions and 23 deletions
@@ -30,6 +30,9 @@ import com.vitorpamplona.amethyst.service.relayClient.authCommand.model.ScreenAu
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.normalizeRelayUrlOrNull
/** The owner pubkey of an addressable venue (`kind:pubkey:dTag`), or null for a bare channel id. */
private fun venueOwnerPubkey(venueId: String): String? = venueId.split(':').getOrNull(1)?.takeIf { it.length == 64 }
@Composable
fun RelayAuthSubscription(accountViewModel: AccountViewModel) = RelayAuthSubscription(accountViewModel, Amethyst.instance.authCoordinator)
@@ -61,6 +64,13 @@ fun RelayAuthSubscription(
// Any follow list (kind 3, follow sets, etc.) counts as trusting the counterparty
// enough to reveal our identity to a relay that serves them.
isFollowed = { pubkey -> pubkey in account.allFollows.flow.value.authors },
// A venue (public chat / community / live stream) is trusted if we've joined it, or
// its owner — the pubkey in a `kind:pubkey:dTag` address — is someone we follow.
isTrustedVenue = { venueId ->
venueId in account.publicChatList.flowSet.value ||
venueId in account.communityList.flowSet.value ||
venueOwnerPubkey(venueId)?.let { it in account.allFollows.flow.value.authors } == true
},
readTrustEnabled = { account.settings.relayAuthTrustFollowsForReads.value },
)
}
@@ -265,13 +265,23 @@ private fun reasonRes(kind: AuthPurposeKind): Int =
AuthPurposeKind.SEND_DM -> R.string.relay_auth_reason_send_dm
AuthPurposeKind.NOTIFY_INBOX -> R.string.relay_auth_reason_notify_inbox
AuthPurposeKind.READ_OUTBOX -> R.string.relay_auth_reason_read_outbox
AuthPurposeKind.POST_VENUE -> R.string.relay_auth_reason_post_venue
AuthPurposeKind.READ_VENUE -> R.string.relay_auth_reason_read_venue
AuthPurposeKind.MY_OWN_RELAY -> R.string.relay_auth_reason_my_own_relay
AuthPurposeKind.OTHER -> R.string.relay_auth_reason_other
}
/** The purpose whose counterparties best describe what the user was doing (most user-facing first). */
/** The purpose that best describes what the user was doing (most user-facing first). */
private fun List<AuthPurpose>.primaryNamed(): AuthPurpose? =
listOf(AuthPurposeKind.SEND_DM, AuthPurposeKind.NOTIFY_INBOX, AuthPurposeKind.READ_OUTBOX)
.firstNotNullOfOrNull { kind -> firstOrNull { it.kind == kind && it.counterparties.isNotEmpty() } }
listOf(
AuthPurposeKind.SEND_DM,
AuthPurposeKind.NOTIFY_INBOX,
AuthPurposeKind.POST_VENUE,
AuthPurposeKind.READ_OUTBOX,
AuthPurposeKind.READ_VENUE,
).firstNotNullOfOrNull { kind ->
firstOrNull { it.kind == kind && (it.counterparties.isNotEmpty() || it.venues.isNotEmpty()) }
}
@Composable
private fun titleFor(
@@ -282,6 +292,8 @@ private fun titleFor(
AuthPurposeKind.SEND_DM -> stringRes(R.string.relay_auth_title_send_dm, who ?: "")
AuthPurposeKind.NOTIFY_INBOX -> stringRes(R.string.relay_auth_title_notify, who ?: "")
AuthPurposeKind.READ_OUTBOX -> stringRes(R.string.relay_auth_title_read, who ?: "")
AuthPurposeKind.POST_VENUE -> stringRes(R.string.relay_auth_title_post_venue)
AuthPurposeKind.READ_VENUE -> stringRes(R.string.relay_auth_title_read_venue)
else -> stringRes(R.string.relay_auth_prompt_title)
}
@@ -294,6 +306,8 @@ private fun consequenceFor(
AuthPurposeKind.SEND_DM -> stringRes(R.string.relay_auth_consequence_send_dm, who ?: "")
AuthPurposeKind.NOTIFY_INBOX -> stringRes(R.string.relay_auth_consequence_notify, who ?: "")
AuthPurposeKind.READ_OUTBOX -> stringRes(R.string.relay_auth_consequence_read, who ?: "")
AuthPurposeKind.POST_VENUE -> stringRes(R.string.relay_auth_consequence_post_venue)
AuthPurposeKind.READ_VENUE -> stringRes(R.string.relay_auth_consequence_read_venue)
else -> null
}
@@ -43,6 +43,7 @@ class RelayAuthPermissionLedger(
val isInMyRelayList: (String) -> Boolean = { false },
val isBlocked: (String) -> Boolean = { false },
val isFollowed: (String) -> Boolean = { false },
val isTrustedVenue: (String) -> Boolean = { false },
val readTrustEnabled: () -> Boolean = { false },
) {
/** The authorization verdict for [ctx], taking the challenge's purpose into account. */
@@ -62,9 +63,19 @@ class RelayAuthPermissionLedger(
ctx.purposes.any { p ->
p.kind == AuthPurposeKind.READ_OUTBOX && p.counterparties.any(isFollowed)
},
servesTrustedVenue =
ctx.purposes.any { p ->
(p.kind == AuthPurposeKind.POST_VENUE || p.kind == AuthPurposeKind.READ_VENUE) &&
p.venues.any(isTrustedVenue)
},
readTrustEnabled = readTrustEnabled(),
hasAttributablePurpose =
ctx.purposes.any { it.kind == AuthPurposeKind.MY_OWN_RELAY || it.counterparties.isNotEmpty() },
ctx.purposes.any {
it.kind == AuthPurposeKind.MY_OWN_RELAY ||
it.kind == AuthPurposeKind.OTHER ||
it.counterparties.isNotEmpty() ||
it.venues.isNotEmpty()
},
)
return RelayAuthResolver.resolve(inputs)
}
@@ -25,17 +25,25 @@ import com.vitorpamplona.amethyst.commons.relayauth.AuthPurposeKind
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip28PublicChat.message.ChannelMessageEvent
import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent
/** `a`-address prefixes of venues whose home relay may require auth: NIP-72 communities (34550) and
* NIP-53 live activities (30311). Their addresses are `kind:ownerPubkey:dTag`. */
private val VENUE_ADDRESS_PREFIXES = listOf("34550:", "30311:")
/**
* 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]).
* Pure so it can be unit-tested without the relay client.
*
* - a pending gift wrap (kind 1059) => we're sending a DM to its `p` recipient ([AuthPurposeKind.SEND_DM]);
* - any other pending event carrying `p` tags => we're delivering it to those users' inboxes
* ([AuthPurposeKind.NOTIFY_INBOX]);
* - a subscription filter with `authors` => we're reading those authors' posts ([AuthPurposeKind.READ_OUTBOX]).
* - 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]);
* - a filter with `authors` => reading those authors' posts ([AuthPurposeKind.READ_OUTBOX]);
* - a filter with `#e`/`#a` venue tags => reading a venue ([AuthPurposeKind.READ_VENUE]);
* - anything else we're actively doing but can't attribute => [AuthPurposeKind.OTHER], so the relay
* is prompted about instead of silently failing.
*/
object RelayAuthPurposeDeriver {
fun derive(
@@ -44,26 +52,61 @@ object RelayAuthPurposeDeriver {
): List<AuthPurpose> {
val dmRecipients = mutableSetOf<HexKey>()
val notifyRecipients = mutableSetOf<HexKey>()
val postVenues = mutableSetOf<String>()
var unattributedWrite = false
pendingEvents.forEach { event ->
val pTags = event.tags.mapNotNullTo(mutableSetOf()) { if (it.size > 1 && it[0] == "p") it[1] else null }
if (event.kind == GiftWrapEvent.KIND) {
dmRecipients.addAll(pTags)
} else {
// We're notifying the people the event references, not its author — drop the
// author's own key so a self-p-tag doesn't read as "notify yourself".
notifyRecipients.addAll(pTags - event.pubKey)
val venueAddresses = event.tags.venueAddresses()
when {
event.kind == GiftWrapEvent.KIND -> dmRecipients.addAll(pTags)
event.kind == ChannelMessageEvent.KIND -> event.channelRootId()?.let { postVenues.add(it) }
venueAddresses.isNotEmpty() -> postVenues.addAll(venueAddresses)
pTags.isNotEmpty() -> notifyRecipients.addAll(pTags - event.pubKey)
else -> unattributedWrite = true
}
}
val readAuthors = mutableSetOf<HexKey>()
val readVenues = mutableSetOf<String>()
var unattributedRead = false
activeFilters.values.forEach { filters ->
filters.forEach { filter -> filter.authors?.let(readAuthors::addAll) }
filters.forEach { filter ->
var matched = false
filter.authors?.let {
readAuthors.addAll(it)
matched = true
}
filter.tags?.get("e")?.let {
readVenues.addAll(it)
matched = true
}
filter.tags?.get("a")?.filter { addr -> VENUE_ADDRESS_PREFIXES.any(addr::startsWith) }?.let {
if (it.isNotEmpty()) {
readVenues.addAll(it)
matched = true
}
}
if (!matched) unattributedRead = true
}
}
return buildList {
if (dmRecipients.isNotEmpty()) add(AuthPurpose(AuthPurposeKind.SEND_DM, dmRecipients))
if (notifyRecipients.isNotEmpty()) add(AuthPurpose(AuthPurposeKind.NOTIFY_INBOX, notifyRecipients))
if (postVenues.isNotEmpty()) add(AuthPurpose(AuthPurposeKind.POST_VENUE, venues = postVenues))
if (readAuthors.isNotEmpty()) add(AuthPurpose(AuthPurposeKind.READ_OUTBOX, readAuthors))
if (readVenues.isNotEmpty()) add(AuthPurpose(AuthPurposeKind.READ_VENUE, venues = readVenues))
// Safety net: we're using this relay but couldn't say how — prompt rather than fail silently.
if (isEmpty() && (unattributedWrite || unattributedRead)) add(AuthPurpose(AuthPurposeKind.OTHER))
}
}
/** The venue a channel message posts into: the `e` tag marked "root", else the first `e` tag. */
private fun Event.channelRootId(): HexKey? {
val eTags = tags.filter { it.size > 1 && it[0] == "e" }
return eTags.firstOrNull { it.size > 3 && it[3] == "root" }?.get(1) ?: eTags.firstOrNull()?.get(1)
}
private fun Array<Array<String>>.venueAddresses(): List<String> = mapNotNull { if (it.size > 1 && it[0] == "a" && VENUE_ADDRESS_PREFIXES.any(it[1]::startsWith)) it[1] else null }
}
@@ -366,7 +366,10 @@ private fun reasonRes(kind: AuthPurposeKind): Int =
AuthPurposeKind.SEND_DM -> R.string.relay_auth_reason_send_dm
AuthPurposeKind.NOTIFY_INBOX -> R.string.relay_auth_reason_notify_inbox
AuthPurposeKind.READ_OUTBOX -> R.string.relay_auth_reason_read_outbox
AuthPurposeKind.POST_VENUE -> R.string.relay_auth_reason_post_venue
AuthPurposeKind.READ_VENUE -> R.string.relay_auth_reason_read_venue
AuthPurposeKind.MY_OWN_RELAY -> R.string.relay_auth_reason_my_own_relay
AuthPurposeKind.OTHER -> R.string.relay_auth_reason_other
}
@Composable
+7
View File
@@ -835,14 +835,21 @@
<string name="relay_auth_title_send_dm">Send your message to %1$s?</string>
<string name="relay_auth_title_notify">Notify %1$s?</string>
<string name="relay_auth_title_read">Load posts from %1$s?</string>
<string name="relay_auth_title_post_venue">Post to this room?</string>
<string name="relay_auth_title_read_venue">Open this room?</string>
<!-- What happens if the user doesn\'t confirm, tied to what they were doing. %1$s is the person. -->
<string name="relay_auth_consequence_send_dm">If you don\'t, your message to %1$s won\'t be delivered.</string>
<string name="relay_auth_consequence_notify">If you don\'t, %1$s won\'t be notified about this.</string>
<string name="relay_auth_consequence_read">If you don\'t, you won\'t see posts from %1$s here.</string>
<string name="relay_auth_consequence_post_venue">If you don\'t, your message won\'t be posted.</string>
<string name="relay_auth_consequence_read_venue">If you don\'t, you won\'t see it here.</string>
<string name="relay_auth_name_and_others">%1$s and others</string>
<string name="relay_auth_reason_send_dm">Send your private message to:</string>
<string name="relay_auth_reason_notify_inbox">Notify:</string>
<string name="relay_auth_reason_read_outbox">Download posts from:</string>
<string name="relay_auth_reason_post_venue">Post to this room</string>
<string name="relay_auth_reason_read_venue">Open this room</string>
<string name="relay_auth_reason_other">Use this relay</string>
<string name="relay_auth_reason_my_own_relay">Connect to your own relay</string>
<string name="relay_auth_and_others">…and others</string>
<string name="relay_auth_allow_once">Allow once</string>
@@ -97,9 +97,47 @@ class RelayAuthPurposeDeriverTest {
}
@Test
fun noAttributableWorkYieldsNoPurposes() {
fun noActivityYieldsNoPurposes() {
assertEquals(emptyList<AuthPurpose>(), RelayAuthPurposeDeriver.derive(emptyList(), emptyMap()))
// an event with no p tags gives nothing to attribute a notification to
assertEquals(emptyList<AuthPurpose>(), RelayAuthPurposeDeriver.derive(listOf(event(1)), emptyMap()))
}
@Test
fun unattributableActivityYieldsOtherAsSafetyNet() {
// An event we can't attribute (no p tags, not a venue) still prompts rather than fail silently.
val purposes = RelayAuthPurposeDeriver.derive(listOf(event(1)), emptyMap())
assertEquals(listOf(AuthPurposeKind.OTHER), purposes.map { it.kind })
}
@Test
fun channelMessageBecomesPostVenue() {
val channelId = "e".repeat(64)
val ev =
Event(
id = "00".repeat(32),
pubKey = "11".repeat(32),
createdAt = 1_700_000_000L,
kind = 42,
tags = arrayOf(arrayOf("e", channelId, "", "root")),
content = "hi",
sig = "22".repeat(64),
)
val purposes = RelayAuthPurposeDeriver.derive(listOf(ev), emptyMap())
assertEquals(1, purposes.size)
assertEquals(AuthPurposeKind.POST_VENUE, purposes[0].kind)
assertEquals(setOf(channelId), purposes[0].venues)
}
@Test
fun communityAndLiveSubscriptionsBecomeReadVenue() {
val community = "34550:${"1".repeat(64)}:my-community"
val live = "30311:${"2".repeat(64)}:my-stream"
val purposes =
RelayAuthPurposeDeriver.derive(
emptyList(),
mapOf("sub" to listOf(Filter(tags = mapOf("a" to listOf(community, live))))),
)
assertEquals(1, purposes.size)
assertEquals(AuthPurposeKind.READ_VENUE, purposes[0].kind)
assertEquals(setOf(community, live), purposes[0].venues)
}
}
@@ -38,17 +38,29 @@ enum class AuthPurposeKind {
/** Reading an author's posts from their NIP-65 outbox (write relays). */
READ_OUTBOX,
/** Posting into a venue — a NIP-28 public chat or a NIP-72 community — hosted on this relay. */
POST_VENUE,
/** Reading a venue's content (public chat / community) from this relay. */
READ_VENUE,
/** The relay is in the user's own relay list. */
MY_OWN_RELAY,
/** We're actively using this relay but couldn't attribute a specific purpose (safety net so we
* prompt instead of silently failing). */
OTHER,
}
/**
* A single reason a relay connection needs auth, with the counterparties it concerns.
* [counterparties] is empty for [AuthPurposeKind.MY_OWN_RELAY].
* A single reason a relay connection needs auth. [counterparties] holds the people it concerns
* (pubkeys, for DM/notify/outbox purposes); [venues] holds venue identifiers (channel event-ids or
* community `a`-addresses, for [AuthPurposeKind.POST_VENUE]/[AuthPurposeKind.READ_VENUE]).
*/
data class AuthPurpose(
val kind: AuthPurposeKind,
val counterparties: Set<String> = emptySet(),
val venues: Set<String> = emptySet(),
)
/** The relay plus every live reason we currently have to auth with it. */
@@ -33,6 +33,8 @@ package com.vitorpamplona.amethyst.commons.relayauth
* (send DM / deliver notification) for this relay.
* @param servesFollowedReadCounterparty a followed user is a counterparty of a *read* purpose
* (download their outbox) for this relay.
* @param servesTrustedVenue this relay hosts a venue (public chat, community, or live stream) the
* user has joined, or whose owner they follow. Trusts both reading and posting to it.
* @param readTrustEnabled the "also trust follows' outboxes when reading" sub-toggle.
* @param hasAttributablePurpose we know *why* this relay wants auth (so a prompt can explain it).
* When false, an unresolved challenge is denied silently rather than prompting.
@@ -44,6 +46,7 @@ data class RelayAuthInputs(
val isInMyRelayList: Boolean,
val servesFollowedWriteCounterparty: Boolean,
val servesFollowedReadCounterparty: Boolean,
val servesTrustedVenue: Boolean,
val readTrustEnabled: Boolean,
val hasAttributablePurpose: Boolean,
)
@@ -57,9 +60,9 @@ data class RelayAuthInputs(
* - [RelayAuthPolicy.NEVER] → DENY
* - [RelayAuthPolicy.ALWAYS] → ALLOW
* - [RelayAuthPolicy.IF_IN_MY_LIST] → ALLOW if in my list, else fall through
* - [RelayAuthPolicy.TRUSTED_FOLLOWS] → ALLOW if in my list, or a followed counterparty is
* served for a write purpose (DM/notification), or (when [RelayAuthInputs.readTrustEnabled])
* for a read purpose; else fall through
* - [RelayAuthPolicy.TRUSTED_FOLLOWS] → ALLOW if in my list, a venue the user joined/follows is
* served, a followed counterparty is served for a write purpose (DM/notification), or (when
* [RelayAuthInputs.readTrustEnabled]) for a read purpose; else fall through
* 4. Fall-through → [RelayAuthVerdict.ASK] when the purpose is known, otherwise DENY.
*/
object RelayAuthResolver {
@@ -80,6 +83,7 @@ object RelayAuthResolver {
if (inputs.isInMyRelayList) RelayAuthVerdict.ALLOW else fallThrough(inputs)
RelayAuthPolicy.TRUSTED_FOLLOWS ->
if (inputs.isInMyRelayList ||
inputs.servesTrustedVenue ||
inputs.servesFollowedWriteCounterparty ||
(inputs.readTrustEnabled && inputs.servesFollowedReadCounterparty)
) {
@@ -31,6 +31,7 @@ class RelayAuthResolverTest {
isInMyRelayList: Boolean = false,
servesFollowedWriteCounterparty: Boolean = false,
servesFollowedReadCounterparty: Boolean = false,
servesTrustedVenue: Boolean = false,
readTrustEnabled: Boolean = false,
hasAttributablePurpose: Boolean = true,
) = RelayAuthInputs(
@@ -40,6 +41,7 @@ class RelayAuthResolverTest {
isInMyRelayList = isInMyRelayList,
servesFollowedWriteCounterparty = servesFollowedWriteCounterparty,
servesFollowedReadCounterparty = servesFollowedReadCounterparty,
servesTrustedVenue = servesTrustedVenue,
readTrustEnabled = readTrustEnabled,
hasAttributablePurpose = hasAttributablePurpose,
)
@@ -92,6 +94,13 @@ class RelayAuthResolverTest {
assertEquals(RelayAuthVerdict.ALLOW, resolve(inputs(servesFollowedReadCounterparty = true, readTrustEnabled = true)))
}
@Test
fun trustedFollowsAllowsVenueYouJoinedOrFollow() {
// A public chat / community / live stream you've joined (or whose owner you follow) —
// auto-auth for both reading and posting, regardless of the read sub-toggle.
assertEquals(RelayAuthVerdict.ALLOW, resolve(inputs(servesTrustedVenue = true, readTrustEnabled = false)))
}
@Test
fun trustedFollowsFallsThroughForStranger() {
// Not my relay, no followed counterparty -> prompt when we know why, else silent deny.