Merge pull request #3520 from vitorpamplona/claude/auth-permission-architecture-weyn9x

NIP-42 relay auth: contextual "why", per-situation trust, and prompts
This commit is contained in:
Vitor Pamplona
2026-07-10 18:48:54 -04:00
committed by GitHub
35 changed files with 3198 additions and 194 deletions
@@ -0,0 +1,385 @@
# Contextual AUTH Permissions — Ask *why*, and trust follows
**Date:** 2026-07-01
**Module:** `amethyst` (+ shared bits in `commons`)
**Status:** Implemented — see "As-built" below for where the shipped design diverged from this proposal.
## As-built (final)
The implementation kept this doc's core ideas (purpose derivation, a prompt bus,
per-relay overrides, grant rationale) but the policy model was reshaped during
review:
- **Global mode is `RelayAuthPolicy { ALWAYS, NEVER, CUSTOM }`** — the earlier
`IF_IN_MY_LIST` / `TRUSTED_FOLLOWS` values were dropped. `CUSTOM` applies a
`RelayAuthCustomToggles` set of independent switches: **my relays & venues**,
**read posts from follows**, **message follows**, **message strangers**
(off by default). New-install default is `CUSTOM` with the first three on.
- **`AuthPurpose` is a `data class` (kind + counterparties + venues)** over an
`AuthPurposeKind` enum (SEND_DM, NOTIFY_INBOX, READ_OUTBOX, POST_VENUE,
READ_VENUE, MY_OWN_RELAY, OTHER) — not a sealed interface. Venues (NIP-28
public chats, NIP-72 communities, NIP-53 live activities) are first-class.
- **Settings screen** uses the app's settings design system (`SettingsSection`
card + `SettingsSwitchTile`) for the toggles and a grouped, lazily-rendered
per-relay list (NIP-11 icon, `displayUrl`, tap → relay info).
- **Give-up signal**: quartz's outbox surfaces `onEventGaveUp`, toasted by
`RelayPublishFailureToast`. `auth-required` NAKs never burn the retry budget
(they reset it) so a slow AUTH handshake can't drop the event.
- **Known limitation**: an event queued to a relay the user then *denies* stays
pending in the outbox (auth-required never gives up); evicting it would need a
quartz "give up on relay for this event" API — deferred.
## Context
Now that Amethyst answers NIP-42 relay AUTH challenges, we need to decide
*when* to reveal the user's identity to a relay — and, crucially, to tell the
user **why** an auth is being requested so they can make an informed choice.
The motivating cases:
- **NIP-17 DM send.** The recipient's DM inbox relays (kind 10050) may require
auth. If we silently refuse, the message never leaves the device and the user
has no idea why. We should ask: *"Relay X wants you to log in to deliver your
private message to Alice — allow?"*
- **Public inbox notifications.** Replying to / mentioning / reacting to someone
publishes to *their* NIP-65 inbox (kind 10002 read relays), which may require
auth.
- **Feed download from outboxes.** Reading a followed author's posts may require
auth to *their* write/outbox relays.
We also want an **automatic mode** for users who trust Amethyst's judgement:
auth (or not) based on a follow-graph heuristic — *if I follow the counterparty
(in any follow list), I trust them enough to reveal my identity to the relay
that serves them.* And regardless of mode, **explicit per-relay overrides** must
be able to force-allow or force-block a single relay. The blocked-relay list
(kind 10006) is a hard block.
## What already exists (reuse — do NOT rebuild)
The NIP-42 plumbing and a first-cut permission gate are already in place:
| Piece | Location |
|---|---|
| AUTH challenge receipt, kind-22242 signing, resend-on-OK | `quartz/.../nip01Core/relay/client/auth/RelayAuthenticator.kt`, `RelayAuthStatus.kt`, `nip42RelayAuth/RelayAuthEvent.kt` |
| Permission gate (per logged-in account) | `amethyst/.../service/relayClient/authCommand/model/AuthCoordinator.kt` |
| Decision engine (per-relay override → global policy) | `.../authCommand/model/RelayAuthPermissionLedger.kt` |
| Policy enum `ALWAYS`/`NEVER`/`IF_IN_MY_LIST`, decision enum `ALLOW`/`DENY` | `commons/.../relayauth/RelayAuthPolicy.kt` |
| Per-relay override persistence interface + DataStore impl | `commons/.../relayauth/RelayAuthPermissionStore.kt`, `amethyst/.../authCommand/model/DataStoreRelayAuthPermissionStore.kt` |
| Settings screen (global policy + per-relay list) | `amethyst/.../ui/screen/loggedIn/relayauth/RelayAuthSettingsScreen.kt` |
| Global policy setting, persisted local-only | `AccountSettings.defaultRelayAuthPolicy`, `LocalPreferences` key `DEFAULT_RELAY_AUTH_POLICY` |
| Blocked-relay list (kind 10006) | `amethyst/.../model/nip51Lists/blockedRelays/BlockedRelayListState.kt` (`.flow`) |
| Follow checks | `Account.isFollowing(...)`, `Account.allFollows.flow.value.authors`, `FollowListsState.isUserInFollowSets(...)` |
| DM / NIP-65 relay lookups | `DmRelayListState`, `Nip65RelayListState` (+ per-user via `LocalCache`) |
## The gap
`RelayAuthPermissionLedger.decide(relayUrl)` receives **only a relay URL**. It
resolves ALLOW/DENY **silently and immediately**. Three things are missing:
1. **No purpose/"why".** The decision point can't tell a DM-send from a
feed-read from a stranger's random challenge, so it can't explain itself or
attribute the relay to a counterparty.
2. **No interactive ASK.** `RelayAuthDecision` is binary. A DENY silently drops
the auth (and the send fails with no feedback).
3. **No follow-based trust.** `IF_IN_MY_LIST` only checks *my own* relays, never
"this relay belongs to someone I follow."
## Recommended architecture
Four changes, smallest surface first.
### 1. Carry the *purpose* to the decision point — `AuthPurpose` + an intent registry
New (in `commons/.../relayauth/`, KMP-safe, no Android deps):
```kotlin
sealed interface AuthPurpose {
data class SendDM(val recipients: Set<HexKey>) : AuthPurpose // recipient DM inboxes (10050)
data class NotifyInbox(val recipients: Set<HexKey>) : AuthPurpose // recipient NIP-65 read relays
data class ReadOutbox(val author: HexKey?) : AuthPurpose // author write/outbox relays
data object MyOwnRelay : AuthPurpose // relay in my own lists
data object Unknown : AuthPurpose // bare challenge, no attribution
}
```
The auth path is **reactive** (relay pushes the challenge; the lambda only knows
the URL). Most intent is already recoverable from quartz's per-relay pending
events + active filters (see "Where it lives" below), so the registry below is
**minimal** — only for hints quartz can't infer (e.g. the human recipient behind
an encrypted gift wrap). It lives with the coordinator, since `LocalCache`/
`Account` are main-process only:
```kotlin
// amethyst/.../service/relayClient/authCommand/model/RelayAuthIntentRegistry.kt
class RelayAuthIntentRegistry {
fun register(relay: NormalizedRelayUrl, purpose: AuthPurpose) // short TTL entry
fun purposesFor(relay: NormalizedRelayUrl): List<AuthPurpose> // read at decision time
}
```
Representative registration sites (each already computes its target relays):
- NIP-17 DM send → `SendDM(recipients)` on each recipient DM-inbox relay.
- Reply/mention/reaction broadcast → `NotifyInbox(recipients)`.
- Outbox feed subscriptions → `ReadOutbox(author)`.
*Race note:* keying by relay URL means concurrent purposes can collide; store a
small time-bounded **set** per relay and let the resolver consider all live
entries (the prompt can say "to send your DM to Alice and 2 others"). Acceptable
for a UX hint + trust check; the persisted decision is what actually gates.
### 1b. Persist *why* each relay was granted (grant rationale)
The decision stays **relay-based**, but each relay's stored record must also
remember **why** it was granted, so the settings screen can show, per relay,
purpose-grouped lines of counterparty users (with avatars):
> **wss://inbox.example.com** — Allowed
> · To send DMs to: (avatars) Alice, Bob, Carol
> · To download posts from: (avatars) Dave, Erin
Extend the persisted per-relay record from a bare `RelayAuthDecision` to
`decision + rationale`, where the rationale is an accumulated map keyed by
purpose kind:
```kotlin
// commons/.../relayauth/RelayAuthGrant.kt (new)
data class RelayAuthGrant(
val decision: RelayAuthDecision,
// purpose kind -> counterparty pubkeys seen for this relay under that purpose
val rationale: Map<AuthPurposeKind, Set<HexKey>> = emptyMap(),
val lastUsedAt: Long = 0L,
)
enum class AuthPurposeKind { SEND_DM, NOTIFY_INBOX, READ_OUTBOX, MY_OWN_RELAY }
```
The rationale is **updated every time** an auth is granted/re-used for that
relay: merge the current `AuthPurpose` counterparties into the matching kind's
set and refresh `lastUsedAt`. This keeps the "why" current as new
DMs/notifications/feeds route through the relay. Store only pubkeys — names and
avatars are resolved for display from `LocalCache` at render time, so the store
stays privacy-light and small.
### 2. Add an `ASK` outcome and a context-aware resolver
Extend the decision enum and generalize `decide()`:
```kotlin
enum class RelayAuthDecision { ALLOW, DENY, ASK } // ASK added
class RelayAuthContext(val relayUrl: String, val purposes: List<AuthPurpose>)
```
`RelayAuthPermissionLedger.decide(ctx)` precedence (highest → lowest):
1. **Blocked-relay list** (kind 10006) → `DENY`. Never reveal identity to a
blocked relay, whatever the policy.
2. **Explicit per-relay override** (`RelayAuthPermissionStore`) → return it.
3. **Global policy**:
- `NEVER``DENY`
- `ALWAYS``ALLOW`
- `IF_IN_MY_LIST``ALLOW` if relay ∈ my relay lists, else fall through
- `TRUSTED_FOLLOWS` *(new — see idea A below)*`ALLOW` if relay ∈ my lists
**or** any counterparty in `ctx.purposes` is followed (`Account.allFollows`
/ `FollowListsState.isUserInFollowSets`) and the purpose permits it; else
fall through.
4. **Fall-through**: `ASK` if the purpose is attributable (we can show a reason);
otherwise `DENY` silently (don't prompt for anonymous stranger challenges).
Keep the current relay-only `decide(url)` as a thin overload calling
`decide(RelayAuthContext(url, registry.purposesFor(url)))` so existing callers
compile.
Whenever the resolver yields `ALLOW` and an auth is actually sent — regardless
of *how* it was allowed (auto policy, stored override, or a just-approved ASK) —
call `store.recordUse(relayUrl, purpose)` for each attributed purpose so the
grant rationale (§1b) stays current.
### 3. Surface the ASK prompt to the UI and await the answer
The `signWithAllLoggedInUsers` lambda in `AuthCoordinator` is **already a
`suspend` context**, so the resolver can suspend and await a user decision — no
restructuring of the auth send path.
- Add an event stream on the coordinator (or account):
`SharedFlow<RelayAuthRequest>` where
`RelayAuthRequest(relay, purposes, reply: CompletableDeferred<UserAuthChoice>)`.
(Follows the repo's one-shot-event flow pattern — see `kotlin-flow-state-event-modeling`.)
- A composable observer (registered in the logged-in scaffold) collects the flow
and shows a dialog: *"{relay} requires you to log in to {reason}."* with
actions **Allow once / Always allow this relay / Block this relay**. The last
two write through `RelayAuthPermissionLedger.setDecision(...)`.
- The lambda `await`s the deferred (bounded by a timeout consistent with
`RelayAuthStatus`), then proceeds to sign or returns `emptyList()`.
Reason strings are derived from `AuthPurpose` via a small mapper (resolve
recipient pubkeys → display names through `LocalCache`).
### 4. New policy mode + settings
- Add `TRUSTED_FOLLOWS` to `RelayAuthPolicy` (recommended — idea A).
- `RelayAuthSettingsScreen`: add the new mode with an explanatory blurb; the
per-relay override list already supports force-allow/force-block (now
three-state incl. "ask"). No storage-format change if we keep decisions
per-relay (idea B, recommended default).
## Where it lives: quartz (generic mechanism) vs amethyst (policy + UI)
Goal (per the brief): if the auth+resend mechanism can be made **robust and
generic**, it belongs in **quartz**; only the *semantics* (why / follow-trust /
prompt copy / rationale UI) stay in **amethyst**.
### The resend queue already exists in quartz — and is the "intent registry"
`PoolEventOutbox` / `PoolEventOutboxState` already persist outgoing events
per-relay across reconnects, and `NostrClient.syncFilters(relay)` — called on
connect **and after an auth OK** (`RelayAuthenticator.checkAuthResults`) —
already re-sends pending EVENTs, not just REQ subscriptions. So the park-and-
flush half of idea C is largely built; we just need to make it correct.
It also means we mostly **don't need a separate `RelayAuthIntentRegistry`**:
quartz already knows, per relay, the *pending outgoing events*
(`PoolEventOutbox`) and the *active subscription filters* (`activeRequests`).
That set IS the intent. At AUTH time quartz can hand the injected decision
callback this context; amethyst derives purpose from it (a pending kind-1059
gift wrap → `SendDM`; a REQ whose `authors` are followed → `ReadOutbox`). Keep a
tiny registry only for hints quartz can't infer (e.g. the human recipient behind
a gift wrap, which is encrypted) — but drive the common cases off quartz state.
### Generic fixes to land in quartz (`nip01Core/relay/client/`)
1. **Treat `auth-required` as a first-class deferred state, not a burned retry.**
*(Landed — commit 2.)* The resend-after-auth path already works:
`syncFilters` on the auth `OK` re-sends every still-pending EVENT, so the
common single-round case (send → `auth-required` → auth → resend → accepted)
already delivered. The narrow bug: `PoolEventOutboxState.newResponse` sent
`auth-required` down the generic-failure path, so each NAK consumed the
per-relay retry budget (`isDone() = responses.size > 2 || tries.size > 3`).
Budget exhaustion isn't checked on the NAK itself but on the **next
`newTry`** — i.e. the resend `syncFilters` issues after the auth `OK`. So
across *repeated* rounds (slow external NIP-55 signer, reconnect churn, or a
relay that re-challenges) the saved event could be **evicted right as it was
about to be redelivered**. Fix: `auth-required` records no failure and leaves
`relaysRemaining` untouched (mirrors `StandaloneRelayClient`'s
`!msg.message.startsWith("auth-required")`), so the existing resend can
redeliver no matter how many auth rounds elapse first.
2. **Real retry policy instead of a hard count.** Replace the `>2 / >3` cliff
with bounded retries + backoff, and a **terminal "gave up" notification**
(via `RelayConnectionListener` / a publish-result callback) so events are
never *silently* dropped. `NostrClientPublishExt.publishAndConfirmDetailed`
and `pendingPublishRelaysFor` already give higher layers a confirmation
surface to build on.
3. **Enrich the injected auth-decision callback with pending context.** The
`signWithAllLoggedInUsers = (relayUrl, authTemplate) -> …` hook in
`RelayAuthenticator` currently gets only the URL. Pass a generic
`RelayAuthChallengeContext` carrying the relay's pending events + active
filters, and let it return not just "sign or not" but an outcome that can
**suspend for a host decision**. The `AuthPurpose`/`RelayAuthContext` types
move to a quartz-neutral shape (opaque to quartz); amethyst supplies the
resolver.
4. **Expose an "event is blocked on auth for relay X" signal** so a host UI can
show the prompt and reflect "queued, not lost." A `SharedFlow`/listener on the
client, host-agnostic.
### What stays in amethyst
The *policy and meaning*: blocked-list + follow-graph resolver, purpose/
counterparty derivation (needs `LocalCache`/`Account`, main-process only), the
`TRUSTED_FOLLOWS` mode, the ASK prompt UI, and the per-relay **grant rationale**
persistence + settings rows (§1b). These depend on identity/UI and cannot live
in quartz.
## A few ideas / open decisions
These are the knobs where more than one answer is defensible. Recommendation
first.
- **A. Follow-based trust shape.** *(Recommended: new `TRUSTED_FOLLOWS` policy
mode.)* Cleanest extension of the existing enum + settings radio group.
Alternatives: a separate independent "trust relays of people I follow" toggle
that layers on any base mode (more flexible, more UI); or never-automatic —
follow-status only pre-selects the "remember" button in the ASK dialog (most
conservative).
- **B. Decision memory granularity.** *(Decided: per-relay — the decision gate
is one ALLOW/DENY per relay.)* We keep the gate relay-based but enrich the
stored record with the grant rationale (§1b) so the settings screen can
explain each relay. Rejected alternative: making the *gate itself*
per-purpose × per-relay (allow relay X for DMs but keep asking for feed reads)
— richer but more confusing; the rationale display gives the transparency
without splitting the gate.
- **C. In-flight send when auth isn't yet granted.** *(Recommended: fix
quartz's existing outbox so park-and-flush is the default.)* The queue already
exists (`PoolEventOutbox` + `syncFilters`-after-auth); the work is making
`auth-required` a deferred state (not a burned retry) and adding backoff + a
terminal give-up signal — see the quartz section above. This is strictly
better than the amethyst-only best-effort/retry fallback and is generic, so it
belongs in quartz. Best-effort remains the trivial fallback only if we choose
not to touch quartz.
- **D. Which purposes auto-trust covers.** DMs and public inbox notifications are
clear yes. Outbox/feed reads ("maybe" in the brief) could be a sub-toggle
under `TRUSTED_FOLLOWS` so reading is treated more liberally than writing.
## Files to touch
- `commons/.../relayauth/RelayAuthPolicy.kt` — add `TRUSTED_FOLLOWS`, add `ASK`.
- `commons/.../relayauth/AuthPurpose.kt`**new** sealed hierarchy + `RelayAuthContext` + `AuthPurposeKind`.
- `commons/.../relayauth/RelayAuthGrant.kt`**new** per-relay record (decision + rationale, §1b).
- `commons/.../relayauth/RelayAuthPermissionStore.kt` + `amethyst/.../DataStoreRelayAuthPermissionStore.kt`
— store/load `RelayAuthGrant` (decision + rationale) instead of a bare decision; add a
`recordUse(relayUrl, purpose)` merge that updates the rationale + `lastUsedAt`.
- `amethyst/.../authCommand/model/RelayAuthPermissionLedger.kt` — context-aware
`decide(ctx)`, blocked-list + follow-trust inputs, `ASK` fall-through.
- `amethyst/.../authCommand/model/RelayAuthIntentRegistry.kt`**new, minimal**:
only for hints quartz can't infer (e.g. the recipient behind an encrypted gift
wrap). Common purposes are derived from quartz's pending events + active
filters instead.
**Quartz (generic mechanism — see the quartz section):**
- `quartz/.../nip01Core/relay/client/pool/PoolEventOutboxState.kt` +
`PoolEventOutbox.kt``auth-required` as a pending-auth state excluded from
`isDone()`; bounded retry + backoff; terminal give-up notification.
- `quartz/.../nip01Core/relay/client/auth/RelayAuthenticator.kt` — pass a
`RelayAuthChallengeContext` (pending events + active filters) to the injected
decision hook; allow the hook to suspend for a host decision.
- `quartz/.../nip01Core/relay/client/listeners/RelayConnectionListener.kt` (or a
new client `SharedFlow`) — "event blocked on auth for relay X" + "gave up"
signals. Fold the good `StandaloneRelayClient` auth-retry logic into the
production path.
- `amethyst/.../authCommand/model/AuthCoordinator.kt` — build `RelayAuthContext`
from the registry, emit `RelayAuthRequest` on `ASK`, await the reply.
- Wire the ledger's new inputs where it's constructed (blocked-list flow,
follow-check, relay-ownership lookups from `DmRelayListState`/`Nip65RelayListState`).
- Registration calls at the DM sender, reply/reaction broadcaster, and outbox
feed subscription.
- `amethyst/.../ui/screen/loggedIn/relayauth/RelayAuthSettingsScreen.kt` — new
mode; three-state per-relay overrides; **per-relay rationale rows** grouped by
purpose ("To send DMs to: …", "To download posts from: …") rendering
counterparty avatars + names resolved from `LocalCache`.
- New composable dialog + observer for `RelayAuthRequest`, hosted in the
logged-in scaffold.
## Verification
- **Unit (commons/amethyst JVM):** table-test `decide(ctx)` across the
precedence ladder — blocked beats override beats policy; `TRUSTED_FOLLOWS`
allows a followed-counterparty relay and falls to `ASK` for a stranger;
`Unknown` purpose → silent `DENY`.
- **Quartz outbox (JVM unit tests):** an `auth-required` NAK does **not** advance
`isDone()` and the event survives; after a simulated auth OK, `syncFilters`
re-sends it; a non-auth terminal error still discards; retries honor backoff
and emit a give-up signal instead of a silent drop. Include a race test:
repeated `auth-required` NAKs before auth completes must not drop the event.
- **Intent registry:** register/expire, multi-purpose merge on one relay.
- **Grant rationale:** `recordUse` merges new counterparties into the right
purpose kind, dedupes, refreshes `lastUsedAt`; `allDecisions()`/settings query
returns the grouped rationale for rendering.
- **`amy` interop:** drive a NIP-17 send to a recipient whose 10050 relay
requires auth against a local auth-required relay (`amy serve` / geode) and
confirm the AUTH round-trip + delivery once allowed. (Enforces the
verify-don't-guess rule.)
- **Manual:** send a DM to a followed vs non-followed npub on an auth-required
inbox under each policy mode; confirm the prompt copy names the right reason
and that Always/Block persist.
- `./gradlew :commons:test :amethyst:testDebugUnitTest` and `./gradlew spotlessApply`.
@@ -162,6 +162,10 @@ private object PrefKeys {
const val ALWAYS_ON_NOTIFICATION_SERVICE = "always_on_notification_service"
const val DEFAULT_RELAY_AUTH_POLICY = "default_relay_auth_policy"
const val RELAY_GROUP_VIEW_MODE = "relay_group_view_mode"
const val RELAY_AUTH_TRUST_MY_RELAYS = "relay_auth_trust_my_relays_and_venues"
const val RELAY_AUTH_TRUST_READ_FOLLOWS = "relay_auth_trust_read_follows"
const val RELAY_AUTH_TRUST_MESSAGE_FOLLOWS = "relay_auth_trust_message_follows"
const val RELAY_AUTH_TRUST_MESSAGE_STRANGERS = "relay_auth_trust_message_strangers"
const val SPLIT_NOTIFICATIONS_ENABLED = "split_notifications_enabled"
const val SHOW_MESSAGES_IN_NOTIFICATIONS = "show_messages_in_notifications"
@@ -517,6 +521,10 @@ object LocalPreferences {
putBoolean(PrefKeys.ALWAYS_ON_NOTIFICATION_SERVICE, settings.alwaysOnNotificationService.value)
putString(PrefKeys.DEFAULT_RELAY_AUTH_POLICY, settings.defaultRelayAuthPolicy.value.name)
putString(PrefKeys.RELAY_GROUP_VIEW_MODE, settings.relayGroupViewMode.value.name)
putBoolean(PrefKeys.RELAY_AUTH_TRUST_MY_RELAYS, settings.relayAuthTrustMyRelaysAndVenues.value)
putBoolean(PrefKeys.RELAY_AUTH_TRUST_READ_FOLLOWS, settings.relayAuthTrustReadFollows.value)
putBoolean(PrefKeys.RELAY_AUTH_TRUST_MESSAGE_FOLLOWS, settings.relayAuthTrustMessageFollows.value)
putBoolean(PrefKeys.RELAY_AUTH_TRUST_MESSAGE_STRANGERS, settings.relayAuthTrustMessageStrangers.value)
putBoolean(PrefKeys.SPLIT_NOTIFICATIONS_ENABLED, settings.splitNotificationsEnabled.value)
putBoolean(PrefKeys.SHOW_MESSAGES_IN_NOTIFICATIONS, settings.showMessagesInNotifications.value)
// Any account that reaches a save has its notification filter in its
@@ -636,8 +644,12 @@ object LocalPreferences {
val defaultRelayAuthPolicy =
getString(PrefKeys.DEFAULT_RELAY_AUTH_POLICY, null)
?.let { runCatching { RelayAuthPolicy.valueOf(it) }.getOrNull() }
?: RelayAuthPolicy.IF_IN_MY_LIST
?: RelayAuthPolicy.CUSTOM
val relayGroupViewMode = RelayGroupViewMode.fromName(getString(PrefKeys.RELAY_GROUP_VIEW_MODE, null))
val relayAuthTrustMyRelays = getBoolean(PrefKeys.RELAY_AUTH_TRUST_MY_RELAYS, true)
val relayAuthTrustReadFollows = getBoolean(PrefKeys.RELAY_AUTH_TRUST_READ_FOLLOWS, true)
val relayAuthTrustMessageFollows = getBoolean(PrefKeys.RELAY_AUTH_TRUST_MESSAGE_FOLLOWS, true)
val relayAuthTrustMessageStrangers = getBoolean(PrefKeys.RELAY_AUTH_TRUST_MESSAGE_STRANGERS, false)
val splitNotificationsEnabled = getBoolean(PrefKeys.SPLIT_NOTIFICATIONS_ENABLED, false)
val showMessagesInNotifications = getBoolean(PrefKeys.SHOW_MESSAGES_IN_NOTIFICATIONS, true)
val hasDonatedInVersion = getStringSet(PrefKeys.HAS_DONATED_IN_VERSION, null) ?: setOf()
@@ -847,6 +859,10 @@ object LocalPreferences {
alwaysOnNotificationService = MutableStateFlow(alwaysOnNotificationService),
defaultRelayAuthPolicy = MutableStateFlow(defaultRelayAuthPolicy),
relayGroupViewMode = MutableStateFlow(relayGroupViewMode),
relayAuthTrustMyRelaysAndVenues = MutableStateFlow(relayAuthTrustMyRelays),
relayAuthTrustReadFollows = MutableStateFlow(relayAuthTrustReadFollows),
relayAuthTrustMessageFollows = MutableStateFlow(relayAuthTrustMessageFollows),
relayAuthTrustMessageStrangers = MutableStateFlow(relayAuthTrustMessageStrangers),
splitNotificationsEnabled = MutableStateFlow(splitNotificationsEnabled),
showMessagesInNotifications = MutableStateFlow(showMessagesInNotifications),
backupUserMetadata = latestUserMetadataResolved,
@@ -273,8 +273,13 @@ class AccountSettings(
var callVideoResolution: CallVideoResolution = CallVideoResolution.HD_720,
var callMaxBitrateBps: Int = 1_500_000,
val callsEnabled: MutableStateFlow<Boolean> = MutableStateFlow(true),
val defaultRelayAuthPolicy: MutableStateFlow<RelayAuthPolicy> = MutableStateFlow(RelayAuthPolicy.IF_IN_MY_LIST),
val defaultRelayAuthPolicy: MutableStateFlow<RelayAuthPolicy> = MutableStateFlow(RelayAuthPolicy.CUSTOM),
val relayGroupViewMode: MutableStateFlow<RelayGroupViewMode> = MutableStateFlow(RelayGroupViewMode.DEFAULT),
// The per-situation toggles applied under RelayAuthPolicy.CUSTOM.
val relayAuthTrustMyRelaysAndVenues: MutableStateFlow<Boolean> = MutableStateFlow(true),
val relayAuthTrustReadFollows: MutableStateFlow<Boolean> = MutableStateFlow(true),
val relayAuthTrustMessageFollows: MutableStateFlow<Boolean> = MutableStateFlow(true),
val relayAuthTrustMessageStrangers: MutableStateFlow<Boolean> = MutableStateFlow(false),
) : EphemeralChatRepository,
RelayGroupRepository,
PublicChatListRepository {
@@ -1524,6 +1529,24 @@ class AccountSettings(
saveAccountSettings()
}
}
private fun changeToggle(
flow: MutableStateFlow<Boolean>,
enabled: Boolean,
) {
if (flow.value != enabled) {
flow.tryEmit(enabled)
saveAccountSettings()
}
}
fun changeRelayAuthTrustMyRelaysAndVenues(enabled: Boolean) = changeToggle(relayAuthTrustMyRelaysAndVenues, enabled)
fun changeRelayAuthTrustReadFollows(enabled: Boolean) = changeToggle(relayAuthTrustReadFollows, enabled)
fun changeRelayAuthTrustMessageFollows(enabled: Boolean) = changeToggle(relayAuthTrustMessageFollows, enabled)
fun changeRelayAuthTrustMessageStrangers(enabled: Boolean) = changeToggle(relayAuthTrustMessageStrangers, enabled)
}
@Serializable
@@ -24,12 +24,17 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.remember
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthCustomToggles
import com.vitorpamplona.amethyst.service.relayClient.authCommand.model.AuthCoordinator
import com.vitorpamplona.amethyst.service.relayClient.authCommand.model.RelayAuthPermissionLedger
import com.vitorpamplona.amethyst.service.relayClient.authCommand.model.ScreenAuthAccount
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.quartz.nip01Core.core.Address
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? = Address.parse(venueId)?.pubKeyHex
@Composable
fun RelayAuthSubscription(accountViewModel: AccountViewModel) = RelayAuthSubscription(accountViewModel, Amethyst.instance.authCoordinator)
@@ -50,10 +55,31 @@ fun RelayAuthSubscription(
RelayAuthPermissionLedger(
store = Amethyst.instance.relayAuthPermissionStore,
globalPolicy = { account.settings.defaultRelayAuthPolicy.value },
customToggles = {
RelayAuthCustomToggles(
myRelaysAndVenues = account.settings.relayAuthTrustMyRelaysAndVenues.value,
readFollows = account.settings.relayAuthTrustReadFollows.value,
messageFollows = account.settings.relayAuthTrustMessageFollows.value,
messageStrangers = account.settings.relayAuthTrustMessageStrangers.value,
)
},
isInMyRelayList = { relayUrl ->
val normalized = relayUrl.normalizeRelayUrlOrNull() ?: return@RelayAuthPermissionLedger false
normalized !in account.blockedRelayList.flow.value &&
normalized in account.trustedRelays.flow.value
normalized in account.trustedRelays.flow.value
},
isBlocked = { relayUrl ->
val normalized = relayUrl.normalizeRelayUrlOrNull() ?: return@RelayAuthPermissionLedger false
normalized in account.blockedRelayList.flow.value
},
// 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
},
)
}
@@ -0,0 +1,63 @@
/*
* 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.compose
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.relayauth.AuthPurposeKind
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.quartz.nip01Core.core.HexKey
/**
* Loads [pubkey] from the local cache, get-or-creating (and subscribing) if absent, then hands the
* [User] (or null while it loads) to [content]. Shared by the auth prompt dialog and the relay-auth
* settings screen, which both render a person by pubkey while their metadata streams in.
*/
@Composable
internal fun LoadRelayAuthUser(
pubkey: HexKey,
accountViewModel: AccountViewModel,
content: @Composable (User?) -> Unit,
) {
var user by remember(pubkey) { mutableStateOf(accountViewModel.getUserIfExists(pubkey)) }
if (user == null) {
LaunchedEffect(pubkey) { user = accountViewModel.checkGetOrCreateUser(pubkey) }
}
content(user)
}
/** The string explaining a single [AuthPurposeKind] ("To send DMs to", "To download posts from", …). */
internal fun relayAuthReasonRes(kind: AuthPurposeKind): Int =
when (kind) {
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
}
@@ -0,0 +1,371 @@
/*
* 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.compose
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.model.Channel
import com.vitorpamplona.amethyst.commons.relayauth.AuthPurpose
import com.vitorpamplona.amethyst.commons.relayauth.AuthPurposeKind
import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthPolicy
import com.vitorpamplona.amethyst.service.relayClient.authCommand.model.RelayAuthPrompt
import com.vitorpamplona.amethyst.service.relayClient.authCommand.model.UserAuthChoice
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.channel.observeChannel
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserInfo
import com.vitorpamplona.amethyst.ui.note.ClickableUserPicture
import com.vitorpamplona.amethyst.ui.note.UsernameDisplay
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.nip01Core.core.HexKey
/** Above this many counterparties for a purpose, collapse the named rows into an avatar facepile. */
private const val NAMED_ROWS_MAX = 2
/** Avatars shown in the facepile before the "+N" overflow badge. */
private const val FACEPILE_MAX = 5
/**
* App-wide host for NIP-42 auth prompts. Collects [RelayAuthPromptBus.prompts] and shows one
* dialog at a time explaining *why* a relay wants the user to log in (who it serves), letting the
* user allow once, always allow, or block the relay. Dismissing answers [UserAuthChoice.DISMISS],
* which the bus also falls back to on timeout, so a relay connection never blocks on the UI.
*/
@Composable
fun RelayAuthPromptHost(accountViewModel: AccountViewModel) {
val bus = remember { Amethyst.instance.authCoordinator.promptBus }
val queue = remember { mutableStateListOf<RelayAuthPrompt>() }
LaunchedEffect(bus) {
bus.prompts.collect { prompt ->
queue.add(prompt)
// Drop the prompt whenever it resolves by any path (answered here, answered on another
// account's dialog, or timed out in the bus) so we never show a stale one.
prompt.onResolved { queue.remove(prompt) }
}
}
queue.firstOrNull { !it.isResolved }?.let { prompt ->
RelayAuthPromptDialog(prompt, accountViewModel) { choice ->
prompt.respond(choice)
queue.remove(prompt)
}
}
}
@Composable
private fun RelayAuthPromptDialog(
prompt: RelayAuthPrompt,
accountViewModel: AccountViewModel,
onChoice: (UserAuthChoice) -> Unit,
) {
// The action the user was actually doing drives the title and the "if you don't" consequence,
// so the out-of-context prompt reconnects to their intent.
val primary = remember(prompt) { prompt.purposes.primaryNamed() }
val who =
primary?.let { p ->
when (p.kind) {
AuthPurposeKind.POST_VENUE, AuthPurposeKind.READ_VENUE ->
p.venues.firstOrNull()?.let { rememberVenueLabel(it, accountViewModel) }
else -> counterpartyLabel(p.counterparties, accountViewModel)
}
}
val showLabels = prompt.purposes.size > 1
AlertDialog(
onDismissRequest = { onChoice(UserAuthChoice.DISMISS) },
icon = {
Icon(
symbol = MaterialSymbols.Shield,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
)
},
title = { Text(titleFor(primary?.kind, who)) },
text = {
Column(
modifier = Modifier.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
Text(stringRes(R.string.relay_auth_prompt_message))
RelayChip(prompt.relayUrl.url)
prompt.purposes.forEach { purpose ->
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
if (showLabels) {
Text(
text = stringRes(relayAuthReasonRes(purpose.kind)),
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
val people = purpose.counterparties.toList()
if (people.size <= NAMED_ROWS_MAX) {
people.forEach { CounterpartyRow(it, accountViewModel) }
} else {
CounterpartyFacepile(people, accountViewModel)
}
}
}
consequenceFor(primary?.kind, who)?.let {
Text(
text = it,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.error,
)
}
}
},
confirmButton = {
Column(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
Button(
onClick = { onChoice(UserAuthChoice.ALLOW_ONCE) },
modifier = Modifier.fillMaxWidth(),
) { Text(stringRes(R.string.relay_auth_allow_once)) }
// For a DM/notification prompt, offer the broad rule: always log in to deliver my
// messages to whoever I'm talking to, so these prompts stop appearing. Those toggles
// only apply under CUSTOM, so switch to it and turn both message toggles on.
if (primary?.kind == AuthPurposeKind.SEND_DM || primary?.kind == AuthPurposeKind.NOTIFY_INBOX) {
FilledTonalButton(
onClick = {
val settings = accountViewModel.account.settings
settings.changeDefaultRelayAuthPolicy(RelayAuthPolicy.CUSTOM)
settings.changeRelayAuthTrustMessageFollows(true)
settings.changeRelayAuthTrustMessageStrangers(true)
onChoice(UserAuthChoice.ALLOW_ONCE)
},
modifier = Modifier.fillMaxWidth(),
) { Text(stringRes(R.string.relay_auth_always_deliver)) }
}
FilledTonalButton(
onClick = { onChoice(UserAuthChoice.ALWAYS_ALLOW) },
modifier = Modifier.fillMaxWidth(),
) { Text(stringRes(R.string.relay_auth_always_allow)) }
TextButton(
onClick = { onChoice(UserAuthChoice.BLOCK) },
modifier = Modifier.fillMaxWidth(),
colors = ButtonDefaults.textButtonColors(contentColor = MaterialTheme.colorScheme.error),
) { Text(stringRes(R.string.relay_auth_block)) }
}
},
)
}
@Composable
private fun RelayChip(url: String) {
Surface(
color = MaterialTheme.colorScheme.surfaceVariant,
shape = MaterialTheme.shapes.small,
) {
Text(
text = url,
modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp),
style = MaterialTheme.typography.labelLarge,
fontWeight = FontWeight.Medium,
maxLines = 1,
overflow = TextOverflow.MiddleEllipsis,
)
}
}
@Composable
private fun CounterpartyRow(
pubkey: HexKey,
accountViewModel: AccountViewModel,
) {
LoadRelayAuthUser(pubkey, accountViewModel) { user ->
if (user != null) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
ClickableUserPicture(user, 32.dp, accountViewModel)
UsernameDisplay(user, accountViewModel = accountViewModel)
}
}
}
}
@Composable
private fun CounterpartyFacepile(
pubkeys: List<HexKey>,
accountViewModel: AccountViewModel,
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy((-8).dp),
) {
pubkeys.take(FACEPILE_MAX).forEach { pubkey ->
LoadRelayAuthUser(pubkey, accountViewModel) { user ->
if (user != null) {
ClickableUserPicture(
baseUser = user,
size = 30.dp,
accountViewModel = accountViewModel,
modifier = Modifier.border(2.dp, MaterialTheme.colorScheme.surface, CircleShape),
)
}
}
}
val extra = pubkeys.size - FACEPILE_MAX
if (extra > 0) {
Text(
text = "+$extra",
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(start = 14.dp),
)
}
}
}
/** 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.POST_VENUE,
AuthPurposeKind.READ_OUTBOX,
AuthPurposeKind.READ_VENUE,
).firstNotNullOfOrNull { kind ->
firstOrNull { it.kind == kind && (it.counterparties.isNotEmpty() || it.venues.isNotEmpty()) }
}
@Composable
private fun titleFor(
kind: AuthPurposeKind?,
who: String?,
): String =
when (kind) {
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, who ?: "")
AuthPurposeKind.READ_VENUE -> stringRes(R.string.relay_auth_title_read_venue, who ?: "")
else -> stringRes(R.string.relay_auth_prompt_title)
}
@Composable
private fun consequenceFor(
kind: AuthPurposeKind?,
who: String?,
): String? =
when (kind) {
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, who ?: "")
AuthPurposeKind.READ_VENUE -> stringRes(R.string.relay_auth_consequence_read_venue, who ?: "")
else -> null
}
/** A short label for a set of counterparties: the first person's name, or "Alice and others". */
@Composable
private fun counterpartyLabel(
pubkeys: Set<HexKey>,
accountViewModel: AccountViewModel,
): String {
val first = pubkeys.firstOrNull() ?: return ""
val name = rememberDisplayName(first, accountViewModel)
return if (pubkeys.size > 1) stringRes(R.string.relay_auth_name_and_others, name) else name
}
/**
* 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. For channels/live activities it get-or-creates the channel and observes it,
* subscribing to relays for the title so it loads and updates like a person's name; a community's
* name is its NIP-72 d-identifier, taken straight from the address.
*/
@Composable
private fun rememberVenueLabel(
venueId: String,
accountViewModel: AccountViewModel,
): String {
val channel: Channel? =
remember(venueId) {
when {
venueId.length == 64 -> accountViewModel.checkGetOrCreatePublicChatChannel(venueId)
venueId.startsWith("30311:") -> Address.parse(venueId)?.let { accountViewModel.checkGetOrCreateLiveActivityChannel(it) }
else -> null
}
}
if (channel != null) {
// Subscribes for the channel's metadata and recomposes when it arrives.
val state by observeChannel(channel, accountViewModel)
val name = (state?.channel ?: channel).toBestDisplayName()
if (name.isNotBlank()) return name
}
// Community: the d-identifier is the name in NIP-72. Also the fallback for an unresolved channel.
return venueId.substringAfterLast(':').ifEmpty { venueId.take(8) }
}
/** The best display name for [pubkey], reactive to metadata arriving from relays. */
@Composable
private fun rememberDisplayName(
pubkey: HexKey,
accountViewModel: AccountViewModel,
): String {
var user by remember(pubkey) { mutableStateOf(accountViewModel.getUserIfExists(pubkey)) }
if (user == null) {
LaunchedEffect(pubkey) { user = accountViewModel.checkGetOrCreateUser(pubkey) }
}
val loaded = user ?: return pubkey.take(8)
// Reading the observed metadata registers a snapshot read, so the name updates when it arrives.
val metadata by observeUserInfo(loaded, accountViewModel)
return metadata?.info?.bestName() ?: loaded.toBestDisplayName()
}
@@ -21,7 +21,7 @@
package com.vitorpamplona.amethyst.service.relayClient.authCommand.model
import androidx.compose.runtime.Stable
import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthDecision
import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthContext
import com.vitorpamplona.amethyst.isDebug
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
@@ -38,6 +38,7 @@ class ScreenAuthAccount(
class AuthCoordinator(
client: INostrClient,
scope: CoroutineScope,
val promptBus: RelayAuthPromptBus = RelayAuthPromptBus(),
) {
private val authWithAccounts = ListWithUniqueSetCache<ScreenAuthAccount, Account> { it.account }
private val tempAccount by lazy {
@@ -59,22 +60,36 @@ class AuthCoordinator(
client,
scope,
signWithAllLoggedInUsers = { relayUrl, authTemplate ->
val currentLedgers = relayLedgers
val shouldAuth =
if (currentLedgers.isEmpty()) {
true
} else {
var allow = false
for (ledger in currentLedgers) {
if (ledger.decide(relayUrl.url) == RelayAuthDecision.ALLOW) {
allow = true
break
}
}
allow
// Reconstruct *why* this relay wants auth from what we're doing with it, so each
// account's ledger can apply follow-based trust and (later) explain the prompt.
// Built lazily so the no-ledgers auto-allow path below doesn't pay for it.
val context by
lazy(LazyThreadSafetyMode.NONE) {
RelayAuthContext(
relayUrl = relayUrl.url,
purposes =
RelayAuthPurposeDeriver.derive(
pendingEvents = client.activeOutboxEvents(relayUrl),
activeFilters = client.activeRequests(relayUrl),
),
)
}
val currentLedgers = relayLedgers
// Ask the user (only in the ASK case) and fold every account's verdict into one
// decision plus an optional per-relay override to remember.
val outcome =
AuthDecisionResolver.resolve(currentLedgers.map { it.decide(context) }) {
promptBus.requestDecision(relayUrl, context.purposes)
}
outcome.remember?.let { decision ->
currentLedgers.firstOrNull()?.setDecision(relayUrl.url, decision)
}
val shouldAuth = outcome.shouldAuth
if (shouldAuth) {
// Remember why we granted this relay so the settings screen can explain it.
currentLedgers.firstOrNull()?.recordGrant(context)
// distinct() returns Set<Account> (the key type U of ListWithUniqueSetCache)
val results =
authWithAccounts.distinct().mapNotNull {
@@ -0,0 +1,68 @@
/*
* 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.RelayAuthDecision
import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthVerdict
/**
* Combines every logged-in account's [RelayAuthVerdict] into a single decision for whether to
* authenticate with a relay and, when the user is asked, what to remember. Pulled out of
* [AuthCoordinator] so the policy is unit-testable without the relay client, signers, or Compose.
*/
object AuthDecisionResolver {
/**
* @param shouldAuth whether to sign the NIP-42 auth challenge.
* @param remember a per-relay override to persist ([RelayAuthDecision.ALLOW]/[RelayAuthDecision.DENY]),
* or null to leave the relay's stored decision untouched.
*/
data class Outcome(
val shouldAuth: Boolean,
val remember: RelayAuthDecision? = null,
)
/**
* Resolves the combined verdict:
* - **No verdicts** (no ledgers watching) => auto-authenticate; the caller has no policy to apply.
* - **Any [RelayAuthVerdict.ALLOW]** => authenticate without asking.
* - **Any [RelayAuthVerdict.ASK]** (and none allow) => call [prompt] and act on the user's choice,
* remembering ALLOW/DENY for Always-allow / Block.
* - **Otherwise** (all DENY) => do not authenticate.
*
* [prompt] is only invoked in the ASK case, so the no-op paths never build a dialog.
*/
suspend fun resolve(
verdicts: List<RelayAuthVerdict>,
prompt: suspend () -> UserAuthChoice,
): Outcome =
when {
verdicts.isEmpty() -> Outcome(shouldAuth = true)
verdicts.any { it == RelayAuthVerdict.ALLOW } -> Outcome(shouldAuth = true)
verdicts.any { it == RelayAuthVerdict.ASK } ->
when (prompt()) {
UserAuthChoice.ALLOW_ONCE -> Outcome(shouldAuth = true)
UserAuthChoice.ALWAYS_ALLOW -> Outcome(shouldAuth = true, remember = RelayAuthDecision.ALLOW)
UserAuthChoice.BLOCK -> Outcome(shouldAuth = false, remember = RelayAuthDecision.DENY)
UserAuthChoice.DISMISS -> Outcome(shouldAuth = false)
}
else -> Outcome(shouldAuth = false)
}
}
@@ -22,12 +22,15 @@ package com.vitorpamplona.amethyst.service.relayClient.authCommand.model
import android.content.Context
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.MutablePreferences
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import com.vitorpamplona.amethyst.commons.relayauth.AuthPurposeKind
import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthDecision
import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthPermissionStore
import com.vitorpamplona.quartz.utils.TimeUtils
import kotlinx.coroutines.flow.first
import java.io.File
import java.security.MessageDigest
@@ -64,9 +67,9 @@ class DataStoreRelayAuthPermissionStore(
}
override suspend fun clearDecision(relayUrl: String) {
store.edit {
it.remove(decisionKey(relayUrl))
it.remove(urlKey(relayUrl))
store.edit { prefs ->
prefs.remove(decisionKey(relayUrl))
pruneUrlIfEmpty(prefs, relayUrl)
}
}
@@ -84,13 +87,129 @@ class DataStoreRelayAuthPermissionStore(
return result
}
override suspend fun recordUse(
relayUrl: String,
additions: Map<AuthPurposeKind, Set<String>>,
) {
if (additions.isEmpty()) return
// Auth is granted again on every reconnect, so avoid a disk write when nothing changed:
// only persist if a purpose gained a counterparty we haven't stored, or the last-used
// timestamp is stale enough to be worth refreshing.
val prefs = store.data.first()
val now = TimeUtils.now()
val lastUsed = prefs[lastUsedKey(relayUrl)]?.toLongOrNull() ?: 0L
val hasNewCounterparty =
additions.any { (kind, pubkeys) ->
val existing = prefs[rationaleKey(relayUrl, kind)].toPubkeySet()
!existing.containsAll(pubkeys) && existing.size < MAX_COUNTERPARTIES_STORED
}
if (!hasNewCounterparty && now - lastUsed < LAST_USED_REFRESH_SECS) return
store.edit { edit ->
edit[urlKey(relayUrl)] = relayUrl
edit[lastUsedKey(relayUrl)] = now.toString()
for ((kind, pubkeys) in additions) {
if (pubkeys.isEmpty()) continue
val key = rationaleKey(relayUrl, kind)
val existing = edit[key].toPubkeySet()
if (existing.size >= MAX_COUNTERPARTIES_STORED || existing.containsAll(pubkeys)) continue
// Cap the stored set: an outbox relay can serve a large slice of the follow list and
// we only need a representative sample to explain the grant in settings.
edit[key] = (existing + pubkeys).take(MAX_COUNTERPARTIES_STORED).joinToString(SEPARATOR)
}
}
}
override suspend fun clearRationale(relayUrl: String) {
store.edit { prefs ->
AuthPurposeKind.entries.forEach { prefs.remove(rationaleKey(relayUrl, it)) }
pruneUrlIfEmpty(prefs, relayUrl)
}
}
override suspend fun allLastUsed(): Map<String, Long> {
val prefs = store.data.first()
val result = mutableMapOf<String, Long>()
for ((key, value) in prefs.asMap()) {
val name = key.name
if (!name.startsWith(LAST_USED_PREFIX)) continue
val hash = name.removePrefix(LAST_USED_PREFIX)
val url = prefs[stringPreferencesKey("$URL_PREFIX$hash")] ?: continue
val ts = (value as? String)?.toLongOrNull() ?: continue
result[url] = ts
}
return result
}
/** Removes the shared url + last-used keys once a relay has neither an override nor rationale,
* so a partial clear never orphans the reverse-lookup other queries depend on. */
private fun pruneUrlIfEmpty(
prefs: MutablePreferences,
relayUrl: String,
) {
val hasDecision = prefs[decisionKey(relayUrl)] != null
val hasRationale = AuthPurposeKind.entries.any { prefs[rationaleKey(relayUrl, it)] != null }
if (!hasDecision && !hasRationale) {
prefs.remove(urlKey(relayUrl))
prefs.remove(lastUsedKey(relayUrl))
}
}
override suspend fun loadRationale(relayUrl: String): Map<AuthPurposeKind, Set<String>> {
val prefs = store.data.first()
return buildMap {
for (kind in AuthPurposeKind.entries) {
val pubkeys = prefs[rationaleKey(relayUrl, kind)].toPubkeySet()
if (pubkeys.isNotEmpty()) put(kind, pubkeys)
}
}
}
override suspend fun allRationales(): Map<String, Map<AuthPurposeKind, Set<String>>> {
val prefs = store.data.first()
val result = mutableMapOf<String, MutableMap<AuthPurposeKind, Set<String>>>()
for ((key, value) in prefs.asMap()) {
val name = key.name
if (!name.startsWith(RATIONALE_PREFIX)) continue
val rest = name.removePrefix(RATIONALE_PREFIX) // "<hash>:<KIND>"
val hash = rest.substringBefore(':')
val kind = runCatching { AuthPurposeKind.valueOf(rest.substringAfter(':')) }.getOrNull() ?: continue
val url = prefs[stringPreferencesKey("$URL_PREFIX$hash")] ?: continue
val pubkeys = (value as? String).toPubkeySet()
if (pubkeys.isNotEmpty()) {
result.getOrPut(url) { mutableMapOf() }[kind] = pubkeys
}
}
return result
}
private fun String?.toPubkeySet(): Set<String> = this?.split(SEPARATOR)?.filterTo(mutableSetOf()) { it.isNotEmpty() } ?: emptySet()
private fun decisionKey(relayUrl: String) = stringPreferencesKey("$DECISION_PREFIX${hash(relayUrl)}")
private fun urlKey(relayUrl: String) = stringPreferencesKey("$URL_PREFIX${hash(relayUrl)}")
private fun rationaleKey(
relayUrl: String,
kind: AuthPurposeKind,
) = stringPreferencesKey("$RATIONALE_PREFIX${hash(relayUrl)}:${kind.name}")
private fun lastUsedKey(relayUrl: String) = stringPreferencesKey("$LAST_USED_PREFIX${hash(relayUrl)}")
companion object {
private const val DECISION_PREFIX = "allow:"
private const val URL_PREFIX = "url:"
private const val RATIONALE_PREFIX = "rat:"
private const val LAST_USED_PREFIX = "used:"
private const val SEPARATOR = ","
/** Cap on counterparties remembered per relay+purpose, so a large outbox author set can't
* bloat the DataStore file or the settings screen. */
private const val MAX_COUNTERPARTIES_STORED = 64
/** Minimum seconds between last-used refreshes when no new counterparty appears. */
private const val LAST_USED_REFRESH_SECS = 300L
private fun hash(relayUrl: String): String {
val digest = MessageDigest.getInstance("SHA-256").digest(relayUrl.toByteArray())
@@ -20,34 +20,83 @@
*/
package com.vitorpamplona.amethyst.service.relayClient.authCommand.model
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.RelayAuthInputs
import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthPermissionStore
import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthPolicy
import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthResolver
import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthVerdict
/**
* Decides whether Amethyst should authenticate with a given relay (NIP-42).
* Decides whether Amethyst should authenticate with a given relay (NIP-42), for one account.
*
* Decision order:
* 1. Per-relay override stored in [store] always wins.
* 2. [globalPolicy]:
* - [RelayAuthPolicy.ALWAYS] [RelayAuthDecision.ALLOW]
* - [RelayAuthPolicy.NEVER] [RelayAuthDecision.DENY]
* - [RelayAuthPolicy.IF_IN_MY_LIST] [RelayAuthDecision.ALLOW] iff [isInMyRelayList] returns true.
* Precedence (see [RelayAuthResolver]): blocked-relay list per-relay override global
* [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.
*/
class RelayAuthPermissionLedger(
val store: RelayAuthPermissionStore,
val globalPolicy: () -> RelayAuthPolicy,
val customToggles: () -> RelayAuthCustomToggles = { RelayAuthCustomToggles() },
val isInMyRelayList: (String) -> Boolean = { false },
val isBlocked: (String) -> Boolean = { false },
val isFollowed: (String) -> Boolean = { false },
val isTrustedVenue: (String) -> Boolean = { false },
) {
/** The authorization verdict for [relayUrl]. */
suspend fun decide(relayUrl: String): RelayAuthDecision {
store.loadDecision(relayUrl)?.let { return it }
return when (globalPolicy()) {
RelayAuthPolicy.ALWAYS -> RelayAuthDecision.ALLOW
RelayAuthPolicy.NEVER -> RelayAuthDecision.DENY
RelayAuthPolicy.IF_IN_MY_LIST ->
if (isInMyRelayList(relayUrl)) RelayAuthDecision.ALLOW else RelayAuthDecision.DENY
}
/** The authorization verdict for [ctx], taking the challenge's purpose into account. */
suspend fun decide(ctx: RelayAuthContext): RelayAuthVerdict {
fun isWrite(kind: AuthPurposeKind) = kind == AuthPurposeKind.SEND_DM || kind == AuthPurposeKind.NOTIFY_INBOX
val inputs =
RelayAuthInputs(
storedOverride = store.loadDecision(ctx.relayUrl),
isBlocked = isBlocked(ctx.relayUrl),
policy = globalPolicy(),
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)
},
// Reading a followed author's outbox.
servesFollowedReadCounterparty =
ctx.purposes.any { p ->
p.kind == AuthPurposeKind.READ_OUTBOX && p.counterparties.any(isFollowed)
},
// Messaging a followed user's inbox (DM / notification).
servesFollowedWriteCounterparty =
ctx.purposes.any { p -> isWrite(p.kind) && p.counterparties.any(isFollowed) },
// Messaging a non-followed user's inbox.
servesStrangerWriteCounterparty =
ctx.purposes.any { p -> isWrite(p.kind) && p.counterparties.any { !isFollowed(it) } },
hasAttributablePurpose =
ctx.purposes.any {
it.kind == AuthPurposeKind.MY_OWN_RELAY ||
it.kind == AuthPurposeKind.OTHER ||
it.counterparties.isNotEmpty() ||
it.venues.isNotEmpty()
},
)
return RelayAuthResolver.resolve(inputs)
}
/** Convenience for callers with no purpose context (e.g. a bare challenge). */
suspend fun decide(relayUrl: String): RelayAuthVerdict = decide(RelayAuthContext(relayUrl))
/**
* Records why [ctx]'s relay was authenticated with, so the settings screen can show the
* counterparties behind each grant. Only purposes that name counterparties are recorded.
*/
suspend fun recordGrant(ctx: RelayAuthContext) {
val additions =
ctx.purposes
.filter { it.counterparties.isNotEmpty() }
.associate { it.kind to it.counterparties }
if (additions.isNotEmpty()) store.recordUse(ctx.relayUrl, additions)
}
/** Stores a per-relay override for [relayUrl]. */
@@ -0,0 +1,119 @@
/*
* 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.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.withTimeoutOrNull
/** What the user chose when asked whether to authenticate with a relay. */
enum class UserAuthChoice {
/** Authenticate this one time; keep asking next time. */
ALLOW_ONCE,
/** Authenticate now and remember ALLOW for this relay. */
ALWAYS_ALLOW,
/** Do not authenticate and remember DENY for this relay. */
BLOCK,
/** No decision (dismissed or timed out) — do not authenticate, don't remember. */
DISMISS,
}
/**
* A pending "should I authenticate with this relay?" question, surfaced to the UI. The relay
* connection coroutine is suspended on [reply] until the user (or a timeout) answers.
*/
class RelayAuthPrompt(
val relayUrl: NormalizedRelayUrl,
val purposes: List<AuthPurpose>,
private val reply: CompletableDeferred<UserAuthChoice>,
) {
fun respond(choice: UserAuthChoice) {
reply.complete(choice)
}
/** True once answered by the user or resolved by the bus (e.g. timed out). */
val isResolved: Boolean get() = reply.isCompleted
/** Runs [block] when this prompt is resolved by any path, so the UI can stop showing it. */
fun onResolved(block: () -> Unit) {
reply.invokeOnCompletion { block() }
}
}
/**
* Bridges the background NIP-42 auth path to the UI: when a challenge resolves to ASK, the auth
* coroutine calls [requestDecision] and suspends; a Composable collects [prompts], shows a dialog,
* and calls [RelayAuthPrompt.respond]. Concurrent challenges for the same relay share one prompt so
* the user isn't asked twice, and an unanswered prompt resolves to [UserAuthChoice.DISMISS] after
* [timeoutMs] so a connection never hangs forever waiting on a UI that may not be present.
*/
class RelayAuthPromptBus(
private val timeoutMs: Long = DEFAULT_TIMEOUT_MS,
) {
// replay so a challenge raised *before* the UI host subscribes — cold start, an account switch,
// any moment no RelayAuthPromptHost is collecting — isn't dropped (which would stall the auth
// coroutine the full timeout and then silently DISMISS). The host filters out any already-
// resolved prompt it replays, so re-delivering stale ones is harmless.
private val mutablePrompts = MutableSharedFlow<RelayAuthPrompt>(replay = 32, extraBufferCapacity = 32)
val prompts: SharedFlow<RelayAuthPrompt> = mutablePrompts
private val inFlight = mutableMapOf<NormalizedRelayUrl, CompletableDeferred<UserAuthChoice>>()
suspend fun requestDecision(
relayUrl: NormalizedRelayUrl,
purposes: List<AuthPurpose>,
): UserAuthChoice {
// Suspension can't happen inside synchronized, so we only decide ownership under the lock
// and await outside it. The owner is the challenge that first created the prompt; any
// concurrent challenge for the same relay awaits the same answer.
val (deferred, isOwner) =
synchronized(inFlight) {
inFlight[relayUrl]?.let { it to false }
?: CompletableDeferred<UserAuthChoice>().also { inFlight[relayUrl] = it } to true
}
if (isOwner) mutablePrompts.emit(RelayAuthPrompt(relayUrl, purposes, deferred))
return try {
awaitOrTimeout(deferred)
} finally {
if (isOwner) synchronized(inFlight) { inFlight.remove(relayUrl) }
}
}
private suspend fun awaitOrTimeout(deferred: CompletableDeferred<UserAuthChoice>): UserAuthChoice {
withTimeoutOrNull(timeoutMs) { deferred.await() }?.let { return it }
// Timed out: resolve the deferred so any UI still showing this prompt can drop it, and so a
// concurrent waiter on the same deferred gets an answer too. complete() is a no-op if a late
// user response already won the race.
deferred.complete(UserAuthChoice.DISMISS)
return deferred.await()
}
companion object {
const val DEFAULT_TIMEOUT_MS = 60_000L
}
}
@@ -0,0 +1,117 @@
/*
* 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.quartz.nip01Core.core.Address
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.nip01Core.tags.aTag.ATag
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.nip53LiveActivities.streaming.LiveActivitiesEvent
import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent
import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent
/** Addressable venue kinds whose home relay may require auth: NIP-72 communities and NIP-53 live
* activities. Their `a` addresses are `kind:ownerPubkey:dTag`. */
private val VENUE_KINDS = setOf(CommunityDefinitionEvent.KIND, LiveActivitiesEvent.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]).
* Pure so it can be unit-tested without the relay client.
*
* - 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(
pendingEvents: List<Event>,
activeFilters: Map<String, List<Filter>>,
): List<AuthPurpose> {
val dmRecipients = mutableSetOf<HexKey>()
val notifyRecipients = mutableSetOf<HexKey>()
val postVenues = mutableSetOf<String>()
var unattributedWrite = false
pendingEvents.forEach { event ->
val pubkeys = event.tags.mapNotNull(PTag::parseKey)
val venues = event.tags.mapNotNull(ATag::parseAddress).filter { it.kind in VENUE_KINDS }
when {
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()) }
pubkeys.isNotEmpty() -> notifyRecipients.addAll(pubkeys - event.pubKey)
else -> unattributedWrite = true
}
}
val readAuthors = mutableSetOf<HexKey>()
val readVenues = mutableSetOf<String>()
var unattributedRead = false
activeFilters.values.forEach { filters ->
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")
?.mapNotNull { Address.parse(it) }
?.filter { it.kind in VENUE_KINDS }
?.takeIf { it.isNotEmpty() }
?.let {
readVenues.addAll(it.map(Address::toValue))
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 Array<Array<String>>.channelRootId(): HexKey? = firstNotNullOfOrNull(MarkedETag::parseRoot)?.eventId ?: firstNotNullOfOrNull(ETag::parseId)
}
@@ -43,6 +43,7 @@ import com.vitorpamplona.amethyst.LocalPreferences
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.service.notifications.PushNotificationUtils
import com.vitorpamplona.amethyst.service.relayClient.authCommand.compose.RelayAuthPromptHost
import com.vitorpamplona.amethyst.service.relayClient.authCommand.compose.RelayAuthSubscription
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.AccountFilterAssemblerSubscription
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.AccountForegroundFilterAssemblerSubscription
@@ -82,6 +83,9 @@ fun LoggedInPage(
// Adds this account to the authentication procedures for relays.
RelayAuthSubscription(accountViewModel)
// Shows the "log in to this relay?" dialog when a NIP-42 challenge needs the user to decide.
RelayAuthPromptHost(accountViewModel)
// Loads account information + DMs and Notifications from Relays.
AccountFilterAssemblerSubscription(accountViewModel)
@@ -20,24 +20,29 @@
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.relayauth
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.HorizontalDivider
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SuggestionChip
import androidx.compose.material3.SuggestionChipDefaults
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
@@ -50,25 +55,50 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.RectangleShape
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.relayauth.AuthPurposeKind
import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthDecision
import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthPolicy
import com.vitorpamplona.amethyst.model.nip11RelayInfo.loadRelayInfo
import com.vitorpamplona.amethyst.service.relayClient.authCommand.compose.LoadRelayAuthUser
import com.vitorpamplona.amethyst.service.relayClient.authCommand.model.DataStoreRelayAuthPermissionStore
import com.vitorpamplona.amethyst.service.relayClient.authCommand.model.RelayAuthPermissionLedger
import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarWithBackButton
import com.vitorpamplona.amethyst.ui.note.ClickableUserPicture
import com.vitorpamplona.amethyst.ui.note.timeAgo
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.napplets.PolicyCard
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.SettingsDivider
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.SettingsSection
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.SettingsSwitchTile
import com.vitorpamplona.amethyst.ui.theme.MediumRelayIconModifier
import com.vitorpamplona.amethyst.ui.theme.RelayIconFilter
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.nip01Core.relay.normalizer.normalizeRelayUrlOrNull
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
/** Avatars shown in a relay's facepile before the "+N" overflow badge. */
private const val FACEPILE_MAX = 3
@Composable
fun RelayAuthSettingsScreen(
accountViewModel: AccountViewModel,
@@ -82,171 +112,342 @@ fun RelayAuthSettingsScreen(
val globalPolicy by account.settings.defaultRelayAuthPolicy.collectAsState()
var perRelayOverrides by remember { mutableStateOf<Map<String, RelayAuthDecision>>(emptyMap()) }
var rationales by remember { mutableStateOf<Map<String, Map<AuthPurposeKind, Set<HexKey>>>>(emptyMap()) }
var lastUsed by remember { mutableStateOf<Map<String, Long>>(emptyMap()) }
var reloadKey by remember { mutableIntStateOf(0) }
LaunchedEffect(reloadKey) {
perRelayOverrides = withContext(Dispatchers.IO) { store.allDecisions() }
withContext(Dispatchers.IO) {
perRelayOverrides = store.allDecisions()
rationales = store.allRationales()
lastUsed = store.allLastUsed()
}
}
Scaffold(
topBar = { TopBarWithBackButton(stringResource(R.string.relay_auth_settings_title), nav) },
) { padding ->
Column(
modifier =
Modifier
.fillMaxSize()
.padding(padding)
.verticalScroll(rememberScrollState())
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Text(
text = stringResource(R.string.relay_auth_global_policy),
style = MaterialTheme.typography.titleMedium,
)
Spacer(Modifier.height(4.dp))
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
RelayAuthPolicy.entries.forEach { policy ->
val (titleRes, descRes, symbol) =
when (policy) {
RelayAuthPolicy.ALWAYS ->
Triple(
R.string.relay_auth_policy_always,
R.string.relay_auth_policy_always_desc,
MaterialSymbols.LockOpen,
)
RelayAuthPolicy.NEVER ->
Triple(
R.string.relay_auth_policy_never,
R.string.relay_auth_policy_never_desc,
MaterialSymbols.Lock,
)
RelayAuthPolicy.IF_IN_MY_LIST ->
Triple(
R.string.relay_auth_policy_if_in_my_list,
R.string.relay_auth_policy_if_in_my_list_desc,
MaterialSymbols.PrivacyTip,
)
}
PolicyCard(
selected = globalPolicy == policy,
symbol = symbol,
label = stringResource(titleRes),
description = stringResource(descRes),
onClick = { account.settings.changeDefaultRelayAuthPolicy(policy) },
)
}
// The union of relays we have an override for and relays we've recorded a reason for — so the
// "why we're logged in" info and the override control live together, one row each.
val relayUrls =
remember(perRelayOverrides, rationales, lastUsed) {
(perRelayOverrides.keys + rationales.keys + lastUsed.keys).toSortedSet().toList()
}
Spacer(Modifier.height(8.dp))
HorizontalDivider()
Spacer(Modifier.height(8.dp))
if (perRelayOverrides.isNotEmpty()) {
Text(
text = stringResource(R.string.relay_auth_per_relay_overrides),
style = MaterialTheme.typography.titleMedium,
)
Spacer(Modifier.height(4.dp))
Surface(
color = MaterialTheme.colorScheme.surfaceVariant,
shape = MaterialTheme.shapes.medium,
modifier = Modifier.fillMaxWidth(),
) {
Column(modifier = Modifier.padding(4.dp)) {
perRelayOverrides.entries.sortedBy { it.key }.forEachIndexed { index, (url, decision) ->
if (index > 0) HorizontalDivider(modifier = Modifier.padding(horizontal = 12.dp))
PerRelayOverrideRow(
url = url,
decision = decision,
onRemove = {
scope.launch {
ledger.clearDecision(url)
reloadKey++
}
},
onToggle = {
scope.launch {
val next =
if (decision == RelayAuthDecision.ALLOW) {
RelayAuthDecision.DENY
} else {
RelayAuthDecision.ALLOW
}
ledger.setDecision(url, next)
reloadKey++
}
},
// LazyColumn so only the visible per-relay rows compose (each builds a NIP-11 icon and
// avatars — real per-row work). Blocks 1 & 2 are small and fixed, so they share one item.
LazyColumn(
modifier = Modifier.fillMaxSize().padding(padding),
contentPadding = PaddingValues(horizontal = 16.dp, vertical = 12.dp),
) {
item {
Column(verticalArrangement = Arrangement.spacedBy(20.dp)) {
// Block 1: when to authenticate (the mode).
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
GroupHeader(stringResource(R.string.relay_auth_global_policy))
RelayAuthPolicy.entries.forEach { policy ->
val (titleRes, descRes, symbol) =
when (policy) {
RelayAuthPolicy.ALWAYS ->
Triple(R.string.relay_auth_policy_always, R.string.relay_auth_policy_always_desc, MaterialSymbols.LockOpen)
RelayAuthPolicy.NEVER ->
Triple(R.string.relay_auth_policy_never, R.string.relay_auth_policy_never_desc, MaterialSymbols.Lock)
RelayAuthPolicy.CUSTOM ->
Triple(R.string.relay_auth_policy_custom, R.string.relay_auth_policy_custom_desc, MaterialSymbols.Tune)
}
PolicyCard(
selected = globalPolicy == policy,
symbol = symbol,
label = stringResource(titleRes),
description = stringResource(descRes),
onClick = { account.settings.changeDefaultRelayAuthPolicy(policy) },
)
}
}
// Block 2: what to log in to (the custom toggles), as settings switch tiles.
if (globalPolicy == RelayAuthPolicy.CUSTOM) {
val myRelays by account.settings.relayAuthTrustMyRelaysAndVenues.collectAsState()
val readFollows by account.settings.relayAuthTrustReadFollows.collectAsState()
val messageFollows by account.settings.relayAuthTrustMessageFollows.collectAsState()
val messageStrangers by account.settings.relayAuthTrustMessageStrangers.collectAsState()
SettingsSection(R.string.relay_auth_custom_section) {
SettingsSwitchTile(
icon = MaterialSymbols.Dns,
title = R.string.relay_auth_toggle_my_relays,
description = R.string.relay_auth_toggle_my_relays_desc,
checked = myRelays,
onCheckedChange = { account.settings.changeRelayAuthTrustMyRelaysAndVenues(it) },
)
SettingsDivider()
SettingsSwitchTile(
icon = MaterialSymbols.Download,
title = R.string.relay_auth_toggle_read_follows,
description = R.string.relay_auth_toggle_read_follows_desc,
checked = readFollows,
onCheckedChange = { account.settings.changeRelayAuthTrustReadFollows(it) },
)
SettingsDivider()
SettingsSwitchTile(
icon = MaterialSymbols.Mail,
title = R.string.relay_auth_toggle_message_follows,
description = R.string.relay_auth_toggle_message_follows_desc,
checked = messageFollows,
onCheckedChange = { account.settings.changeRelayAuthTrustMessageFollows(it) },
)
SettingsDivider()
SettingsSwitchTile(
icon = MaterialSymbols.Public,
title = R.string.relay_auth_toggle_message_strangers,
description = R.string.relay_auth_toggle_message_strangers_desc,
checked = messageStrangers,
onCheckedChange = { account.settings.changeRelayAuthTrustMessageStrangers(it) },
)
}
}
// Block 3 header — its rows are the lazy items below.
GroupHeader(stringResource(R.string.relay_auth_per_relay_overrides))
}
Spacer(Modifier.height(8.dp))
}
if (relayUrls.isEmpty()) {
item {
Card(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(20.dp),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainerLow),
elevation = CardDefaults.cardElevation(defaultElevation = 0.dp),
) {
Text(
text = stringResource(R.string.relay_auth_no_overrides),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(16.dp),
)
}
}
} else {
Box(Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) {
Text(
text = stringResource(R.string.relay_auth_no_overrides),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
// Each row is its own lazy item but shares one rounded-card background: the first/last
// clip the top/bottom corners so the contiguous rows read as a single settings card.
itemsIndexed(relayUrls, key = { _, url -> url }) { index, url ->
Column(
modifier =
Modifier
.clip(sectionCardShape(index, relayUrls.size))
.background(MaterialTheme.colorScheme.surfaceContainerLow),
) {
if (index > 0) SettingsDivider()
RelayRow(
url = url,
decision = perRelayOverrides[url],
servedUsers =
rationales[url]
?.values
?.flatten()
?.distinct()
.orEmpty(),
lastUsedSecs = lastUsed[url],
accountViewModel = accountViewModel,
nav = nav,
onToggle = {
scope.launch {
// null (allowed by policy) or ALLOW -> block; DENY -> allow.
val next =
if (perRelayOverrides[url] == RelayAuthDecision.DENY) {
RelayAuthDecision.ALLOW
} else {
RelayAuthDecision.DENY
}
ledger.setDecision(url, next)
reloadKey++
}
},
onForget = {
scope.launch {
// Single "forget" clears both the override and the recorded reason,
// so the relay drops off this list entirely.
ledger.clearDecision(url)
store.clearRationale(url)
reloadKey++
}
},
)
}
}
}
item { Spacer(Modifier.height(16.dp)) }
}
}
}
/** Primary-colored section label, matching [SettingsSection]'s header used across settings. */
@Composable
private fun PerRelayOverrideRow(
private fun GroupHeader(title: String) {
Text(
text = title,
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(horizontal = 4.dp),
)
}
/** Corner shape for one row of a grouped settings card: round the outer corners of the first and
* last rows only, so contiguous rows read as a single rounded card. */
private fun sectionCardShape(
index: Int,
count: Int,
): Shape =
when {
count <= 1 -> RoundedCornerShape(20.dp)
index == 0 -> RoundedCornerShape(topStart = 20.dp, topEnd = 20.dp)
index == count - 1 -> RoundedCornerShape(bottomStart = 20.dp, bottomEnd = 20.dp)
else -> RectangleShape
}
/**
* One relay's row in the per-relay list: NIP-11 icon + shortened URL (tap the row to open the relay's
* info screen), when it was last used, a facepile of the people it serves, an Allow/Deny chip, and a
* Forget button. [decision] is null when the relay is allowed by policy rather than an explicit
* override; the chip still reads "Allowed" and tapping it records an explicit block. Styled to match
* the app's other relay lists (icon-led rows separated by dividers).
*/
@Composable
private fun RelayRow(
url: String,
decision: RelayAuthDecision,
onRemove: () -> Unit,
decision: RelayAuthDecision?,
servedUsers: List<HexKey>,
lastUsedSecs: Long?,
accountViewModel: AccountViewModel,
nav: INav,
onToggle: () -> Unit,
onForget: () -> Unit,
) {
val context = LocalContext.current
val relay = remember(url) { url.normalizeRelayUrlOrNull() }
Row(
modifier =
Modifier
.clickable { nav.nav(Route.RelayInfo(url)) }
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 8.dp),
.padding(start = 16.dp, top = 10.dp, end = 4.dp, bottom = 10.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
RelayIcon(relay, url, accountViewModel)
Column(Modifier.weight(1f)) {
Text(
text = url,
style = MaterialTheme.typography.bodyMedium,
text = relay?.displayUrl() ?: url,
fontSize = 16.sp,
fontWeight = FontWeight.Medium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
overflow = TextOverflow.MiddleEllipsis,
)
}
SuggestionChip(
onClick = onToggle,
label = {
if (servedUsers.isNotEmpty()) {
Spacer(Modifier.height(4.dp))
UserFacepile(servedUsers, accountViewModel)
}
if (lastUsedSecs != null && lastUsedSecs > 0L) {
Text(
text =
if (decision == RelayAuthDecision.ALLOW) {
stringResource(R.string.relay_auth_decision_allow)
} else {
stringResource(R.string.relay_auth_decision_deny)
},
style = MaterialTheme.typography.labelSmall,
text = stringResource(R.string.relay_auth_last_used, timeAgo(lastUsedSecs, context, prefix = "")),
fontSize = 13.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 2.dp),
)
},
colors =
if (decision == RelayAuthDecision.ALLOW) {
SuggestionChipDefaults.suggestionChipColors(
containerColor = MaterialTheme.colorScheme.primaryContainer,
labelColor = MaterialTheme.colorScheme.onPrimaryContainer,
)
} else {
SuggestionChipDefaults.suggestionChipColors(
containerColor = MaterialTheme.colorScheme.errorContainer,
labelColor = MaterialTheme.colorScheme.onErrorContainer,
)
},
)
IconButton(onClick = onRemove) {
Icon(MaterialSymbols.Close, contentDescription = stringResource(R.string.relay_auth_remove_override))
}
}
DecisionChip(decision = decision, onToggle = onToggle)
IconButton(onClick = onForget) {
Icon(MaterialSymbols.Close, contentDescription = stringResource(R.string.relay_auth_forget))
}
}
}
/** The relay's NIP-11 icon (robohash fallback), matching the other relay lists in the app. */
@Composable
private fun RelayIcon(
relay: NormalizedRelayUrl?,
url: String,
accountViewModel: AccountViewModel,
) {
val info = if (relay != null) loadRelayInfo(relay).value else null
RobohashFallbackAsyncImage(
robot = info?.id ?: relay?.displayUrl() ?: url,
model = info?.icon,
contentDescription = stringResource(R.string.relay_info, url),
colorFilter = RelayIconFilter,
modifier = MediumRelayIconModifier,
loadProfilePicture = accountViewModel.settings.showProfilePictures(),
loadRobohash = accountViewModel.settings.isNotPerformanceMode(),
)
}
/** Allow/deny pill for a relay. Green when allowed (explicitly or by policy), red when blocked. */
@Composable
private fun DecisionChip(
decision: RelayAuthDecision?,
onToggle: () -> Unit,
) {
val allowed = decision != RelayAuthDecision.DENY
SuggestionChip(
onClick = onToggle,
label = {
Text(
text = stringResource(if (allowed) R.string.relay_auth_decision_allow else R.string.relay_auth_decision_deny),
style = MaterialTheme.typography.labelSmall,
)
},
colors =
if (allowed) {
SuggestionChipDefaults.suggestionChipColors(
containerColor = MaterialTheme.colorScheme.primaryContainer,
labelColor = MaterialTheme.colorScheme.onPrimaryContainer,
)
} else {
SuggestionChipDefaults.suggestionChipColors(
containerColor = MaterialTheme.colorScheme.errorContainer,
labelColor = MaterialTheme.colorScheme.onErrorContainer,
)
},
)
}
/** Overlapping avatars for the people a relay serves — [FACEPILE_MAX] pictures then a "+N" badge. */
@Composable
private fun UserFacepile(
pubkeys: List<HexKey>,
accountViewModel: AccountViewModel,
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp),
) {
Row(horizontalArrangement = Arrangement.spacedBy((-8).dp)) {
pubkeys.take(FACEPILE_MAX).forEach { pubkey ->
LoadRelayAuthUser(pubkey, accountViewModel) { user ->
if (user != null) {
ClickableUserPicture(
baseUser = user,
size = 28.dp,
accountViewModel = accountViewModel,
modifier = Modifier.border(2.dp, MaterialTheme.colorScheme.surfaceVariant, CircleShape),
)
}
}
}
}
val extra = pubkeys.size - FACEPILE_MAX
if (extra > 0) {
Text(
text = "+$extra",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
+41 -5
View File
@@ -818,18 +818,54 @@
<!-- Relay Authentication (NIP-42) settings -->
<string name="relay_auth_settings_title">Relay Authentication</string>
<string name="relay_auth_search_keywords">auth authentication relay sign verify nip-42 identity</string>
<string name="relay_auth_global_policy">Global policy</string>
<string name="relay_auth_global_policy">When to authenticate</string>
<string name="relay_auth_custom_section">What to log in to</string>
<string name="relay_auth_policy_always">Always authenticate</string>
<string name="relay_auth_policy_always_desc">Sign auth challenges for every relay that requests it</string>
<string name="relay_auth_policy_never">Never authenticate</string>
<string name="relay_auth_policy_never_desc">Ignore auth challenges from all relays</string>
<string name="relay_auth_policy_if_in_my_list">My relays only</string>
<string name="relay_auth_policy_if_in_my_list_desc">Only authenticate with relays in your relay list</string>
<string name="relay_auth_policy_custom">Custom</string>
<string name="relay_auth_policy_custom_desc">Choose exactly which relays to log in to. You\'ll be asked about anything you haven\'t allowed below.</string>
<string name="relay_auth_toggle_my_relays">My relays and venues</string>
<string name="relay_auth_toggle_my_relays_desc">Log in to your own relays and to public chats, communities and live streams you\'ve joined or favorited.</string>
<string name="relay_auth_toggle_read_follows">Read posts from people I follow</string>
<string name="relay_auth_toggle_read_follows_desc">Log in to a follow\'s relays to download their posts.</string>
<string name="relay_auth_toggle_message_follows">Message people I follow</string>
<string name="relay_auth_toggle_message_follows_desc">Log in to a follow\'s relays to send DMs, replies and notifications.</string>
<string name="relay_auth_toggle_message_strangers">Message anyone</string>
<string name="relay_auth_toggle_message_strangers_desc">Log in to strangers\' relays to send DMs, replies and notifications. Off by default; you\'ll be asked each time instead.</string>
<string name="relay_auth_prompt_title">Confirm it\'s you to this relay?</string>
<string name="relay_auth_prompt_message">This relay wants to confirm it\'s really you first. Its operator will see which account you are.</string>
<!-- Action-aware titles: %1$s is the person (or "Alice and others"). -->
<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 %1$s?</string>
<string name="relay_auth_title_read_venue">Open %1$s?</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 to %1$s won\'t be posted.</string>
<string name="relay_auth_consequence_read_venue">If you don\'t, you won\'t see %1$s 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_allow_once">Allow once</string>
<string name="relay_auth_always_allow">Always allow this relay</string>
<string name="relay_auth_always_deliver">Always deliver my messages</string>
<string name="relay_auth_block">Block this relay</string>
<string name="relay_auth_per_relay_overrides">Per-relay overrides</string>
<string name="relay_auth_no_overrides">No per-relay overrides — global policy applies everywhere</string>
<string name="relay_auth_forget">Forget</string>
<string name="relay_auth_last_used">Last used %1$s ago</string>
<string name="relay_auth_no_overrides">Nothing here yet — your global policy applies to every relay.</string>
<string name="relay_auth_decision_allow">Allow</string>
<string name="relay_auth_decision_deny">Deny</string>
<string name="relay_auth_remove_override">Remove override</string>
<string name="nip82_repository_label">Source: %1$s</string>
<string name="nip82_version_label">v%1$s</string>
@@ -0,0 +1,117 @@
/*
* 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.RelayAuthDecision
import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthVerdict
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
class AuthDecisionResolverTest {
/** A prompt that must never be called; fails the test if the resolver asks the user. */
private val neverPrompt: suspend () -> UserAuthChoice = { error("prompt() should not be called") }
@Test
fun noVerdictsAutoAuthenticatesWithoutPrompting() =
runTest {
val outcome = AuthDecisionResolver.resolve(emptyList(), neverPrompt)
assertTrue(outcome.shouldAuth)
assertNull(outcome.remember)
}
@Test
fun anyAllowAuthenticatesWithoutPrompting() =
runTest {
val outcome =
AuthDecisionResolver.resolve(
listOf(RelayAuthVerdict.DENY, RelayAuthVerdict.ALLOW, RelayAuthVerdict.ASK),
neverPrompt,
)
assertTrue(outcome.shouldAuth)
assertNull(outcome.remember)
}
@Test
fun allDenyDoesNotAuthenticateAndDoesNotPrompt() =
runTest {
val outcome =
AuthDecisionResolver.resolve(
listOf(RelayAuthVerdict.DENY, RelayAuthVerdict.DENY),
neverPrompt,
)
assertFalse(outcome.shouldAuth)
assertNull(outcome.remember)
}
@Test
fun askAllowOnceAuthenticatesButRemembersNothing() =
runTest {
val outcome =
AuthDecisionResolver.resolve(listOf(RelayAuthVerdict.ASK)) { UserAuthChoice.ALLOW_ONCE }
assertTrue(outcome.shouldAuth)
assertNull(outcome.remember)
}
@Test
fun askAlwaysAllowAuthenticatesAndRemembersAllow() =
runTest {
val outcome =
AuthDecisionResolver.resolve(listOf(RelayAuthVerdict.ASK)) { UserAuthChoice.ALWAYS_ALLOW }
assertTrue(outcome.shouldAuth)
assertEquals(RelayAuthDecision.ALLOW, outcome.remember)
}
@Test
fun askBlockDoesNotAuthenticateAndRemembersDeny() =
runTest {
val outcome =
AuthDecisionResolver.resolve(listOf(RelayAuthVerdict.ASK)) { UserAuthChoice.BLOCK }
assertFalse(outcome.shouldAuth)
assertEquals(RelayAuthDecision.DENY, outcome.remember)
}
@Test
fun askDismissDoesNotAuthenticateAndRemembersNothing() =
runTest {
val outcome =
AuthDecisionResolver.resolve(listOf(RelayAuthVerdict.ASK)) { UserAuthChoice.DISMISS }
assertFalse(outcome.shouldAuth)
assertNull(outcome.remember)
}
@Test
fun askIsOnlyReachedWhenNoAccountAllows() =
runTest {
// ALLOW present alongside ASK must short-circuit to auth without prompting.
var prompted = false
val outcome =
AuthDecisionResolver.resolve(listOf(RelayAuthVerdict.ASK, RelayAuthVerdict.ALLOW)) {
prompted = true
UserAuthChoice.BLOCK
}
assertTrue(outcome.shouldAuth)
assertFalse(prompted)
}
}
@@ -0,0 +1,177 @@
/*
* 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.AuthPurposeKind
import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthDecision
import kotlinx.coroutines.runBlocking
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Rule
import org.junit.Test
import org.junit.rules.TemporaryFolder
/**
* Round-trip tests for the DataStore-backed relay-auth permission store. Backed by a real
* PreferenceDataStore on a per-test temp directory (the store takes a plain filesDir), so it runs
* on the JVM without Robolectric. A fresh directory per test dodges DataStore's per-file
* single-instance guard.
*/
class DataStoreRelayAuthPermissionStoreTest {
@get:Rule val tmp = TemporaryFolder()
private fun newStore() = DataStoreRelayAuthPermissionStore(tmp.newFolder())
private val relay = "wss://auth.relay.test"
private val other = "wss://other.relay.test"
private val alice = "a".repeat(64)
private val bob = "b".repeat(64)
private val carol = "c".repeat(64)
@Test
fun decisionRoundTrips() =
runBlocking {
val store = newStore()
assertNull(store.loadDecision(relay))
store.storeDecision(relay, RelayAuthDecision.ALLOW)
assertEquals(RelayAuthDecision.ALLOW, store.loadDecision(relay))
store.storeDecision(relay, RelayAuthDecision.DENY)
assertEquals(RelayAuthDecision.DENY, store.loadDecision(relay))
}
@Test
fun allDecisionsReversesTheHashedKeyBackToTheUrl() =
runBlocking {
val store = newStore()
store.storeDecision(relay, RelayAuthDecision.ALLOW)
store.storeDecision(other, RelayAuthDecision.DENY)
assertEquals(
mapOf(relay to RelayAuthDecision.ALLOW, other to RelayAuthDecision.DENY),
store.allDecisions(),
)
}
@Test
fun recordUseMergesCounterpartiesAcrossCallsGroupedByKind() =
runBlocking {
val store = newStore()
store.recordUse(relay, mapOf(AuthPurposeKind.SEND_DM to setOf(alice)))
store.recordUse(relay, mapOf(AuthPurposeKind.SEND_DM to setOf(bob)))
store.recordUse(relay, mapOf(AuthPurposeKind.NOTIFY_INBOX to setOf(carol)))
assertEquals(
mapOf(
AuthPurposeKind.SEND_DM to setOf(alice, bob),
AuthPurposeKind.NOTIFY_INBOX to setOf(carol),
),
store.loadRationale(relay),
)
}
@Test
fun recordUseAlwaysStoresANewCounterpartyEvenRightAfterAWrite() =
runBlocking {
// The write throttle must never drop a genuinely new grant: a second call with a new
// counterparty, moments after the first, still has to persist it.
val store = newStore()
store.recordUse(relay, mapOf(AuthPurposeKind.SEND_DM to setOf(alice)))
store.recordUse(relay, mapOf(AuthPurposeKind.SEND_DM to setOf(bob)))
assertEquals(setOf(alice, bob), store.loadRationale(relay)[AuthPurposeKind.SEND_DM])
}
@Test
fun recordUseIsIdempotentForTheSameCounterparty() =
runBlocking {
val store = newStore()
store.recordUse(relay, mapOf(AuthPurposeKind.SEND_DM to setOf(alice)))
store.recordUse(relay, mapOf(AuthPurposeKind.SEND_DM to setOf(alice)))
assertEquals(setOf(alice), store.loadRationale(relay)[AuthPurposeKind.SEND_DM])
}
@Test
fun recordUseCapsTheStoredCounterpartySet() =
runBlocking {
val store = newStore()
val many = (0 until 100).map { "%064x".format(it) }.toSet()
store.recordUse(relay, mapOf(AuthPurposeKind.READ_OUTBOX to many))
assertEquals(64, store.loadRationale(relay)[AuthPurposeKind.READ_OUTBOX]?.size)
}
@Test
fun recordUsePopulatesLastUsed() =
runBlocking {
val store = newStore()
store.recordUse(relay, mapOf(AuthPurposeKind.SEND_DM to setOf(alice)))
val ts = store.allLastUsed()[relay]
assertTrue("expected a positive last-used timestamp, got $ts", (ts ?: 0L) > 0L)
}
@Test
fun clearRationaleDropsRationaleAndPrunesTheReverseLookupWhenNoDecisionRemains() =
runBlocking {
val store = newStore()
store.recordUse(relay, mapOf(AuthPurposeKind.SEND_DM to setOf(alice)))
store.clearRationale(relay)
assertTrue(store.loadRationale(relay).isEmpty())
// The shared url + last-used keys must be pruned too, or they'd orphan the reverse lookup.
assertFalse(relay in store.allLastUsed())
assertFalse(relay in store.allRationales())
}
@Test
fun clearRationaleKeepsTheReverseLookupWhenADecisionStillRemains() =
runBlocking {
val store = newStore()
store.storeDecision(relay, RelayAuthDecision.ALLOW)
store.recordUse(relay, mapOf(AuthPurposeKind.SEND_DM to setOf(alice)))
store.clearRationale(relay)
// The decision must survive, and allDecisions still resolves the url (reverse lookup intact).
assertEquals(RelayAuthDecision.ALLOW, store.loadDecision(relay))
assertEquals(mapOf(relay to RelayAuthDecision.ALLOW), store.allDecisions())
}
@Test
fun clearDecisionKeepsRationaleAndItsReverseLookup() =
runBlocking {
val store = newStore()
store.storeDecision(relay, RelayAuthDecision.DENY)
store.recordUse(relay, mapOf(AuthPurposeKind.SEND_DM to setOf(alice)))
store.clearDecision(relay)
assertNull(store.loadDecision(relay))
// Rationale (and the url it depends on for allRationales) must survive the decision clear.
assertEquals(
mapOf(AuthPurposeKind.SEND_DM to setOf(alice)),
store.allRationales()[relay],
)
}
}
@@ -0,0 +1,117 @@
/*
* 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.RelayAuthDecision
import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthPermissionStore
import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthPolicy
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Test
/** In-memory [RelayAuthPermissionStore] that unions rationale like the DataStore impl does. */
private class InMemoryStore : RelayAuthPermissionStore {
private val overrides = mutableMapOf<String, RelayAuthDecision>()
private val rationale = mutableMapOf<String, MutableMap<AuthPurposeKind, MutableSet<String>>>()
override suspend fun loadDecision(relayUrl: String) = overrides[relayUrl]
override suspend fun storeDecision(
relayUrl: String,
decision: RelayAuthDecision,
) {
overrides[relayUrl] = decision
}
override suspend fun clearDecision(relayUrl: String) {
overrides.remove(relayUrl)
}
override suspend fun allDecisions() = overrides.toMap()
override suspend fun recordUse(
relayUrl: String,
additions: Map<AuthPurposeKind, Set<String>>,
) {
val forRelay = rationale.getOrPut(relayUrl) { mutableMapOf() }
for ((kind, pubkeys) in additions) {
forRelay.getOrPut(kind) { mutableSetOf() }.addAll(pubkeys)
}
}
override suspend fun loadRationale(relayUrl: String) = rationale[relayUrl]?.mapValues { it.value.toSet() } ?: emptyMap()
}
class RelayAuthGrantRationaleTest {
private val relay = "wss://inbox.relay.test"
private val alice = "a".repeat(64)
private val bob = "b".repeat(64)
private val carol = "c".repeat(64)
private fun ledger(store: RelayAuthPermissionStore) = RelayAuthPermissionLedger(store, { RelayAuthPolicy.CUSTOM })
@Test
fun recordsCounterpartiesGroupedByPurpose() =
runTest {
val store = InMemoryStore()
ledger(store).recordGrant(
RelayAuthContext(
relay,
listOf(
AuthPurpose(AuthPurposeKind.SEND_DM, setOf(alice)),
AuthPurpose(AuthPurposeKind.READ_OUTBOX, setOf(bob)),
),
),
)
assertEquals(
mapOf(
AuthPurposeKind.SEND_DM to setOf(alice),
AuthPurposeKind.READ_OUTBOX to setOf(bob),
),
store.loadRationale(relay),
)
}
@Test
fun mergesNewCounterpartiesAcrossGrants() =
runTest {
val store = InMemoryStore()
val ledger = ledger(store)
ledger.recordGrant(RelayAuthContext(relay, listOf(AuthPurpose(AuthPurposeKind.SEND_DM, setOf(alice)))))
ledger.recordGrant(RelayAuthContext(relay, listOf(AuthPurpose(AuthPurposeKind.SEND_DM, setOf(carol)))))
assertEquals(mapOf(AuthPurposeKind.SEND_DM to setOf(alice, carol)), store.loadRationale(relay))
}
@Test
fun ignoresPurposesWithoutCounterparties() =
runTest {
val store = InMemoryStore()
ledger(store).recordGrant(RelayAuthContext(relay, listOf(AuthPurpose(AuthPurposeKind.MY_OWN_RELAY))))
assertEquals(emptyMap<AuthPurposeKind, Set<String>>(), store.loadRationale(relay))
}
}
@@ -0,0 +1,87 @@
/*
* 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.nip01Core.relay.normalizer.NormalizedRelayUrl
import kotlinx.coroutines.async
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Test
class RelayAuthPromptBusTest {
private val relay = NormalizedRelayUrl("wss://auth.relay.test")
@Test
fun deliversTheUsersChoiceToTheWaitingCaller() =
runTest {
val bus = RelayAuthPromptBus()
val collector = async { bus.prompts.first() }
val caller = async { bus.requestDecision(relay, emptyList()) }
collector.await().respond(UserAuthChoice.ALWAYS_ALLOW)
assertEquals(UserAuthChoice.ALWAYS_ALLOW, caller.await())
}
@Test
fun concurrentChallengesForSameRelayShareOnePrompt() =
runTest {
val bus = RelayAuthPromptBus()
// Capture the single surfaced prompt before the two challenges fire.
val surfaced = async { bus.prompts.first() }
val first = async { bus.requestDecision(relay, emptyList()) }
val second = async { bus.requestDecision(relay, emptyList()) }
surfaced.await().respond(UserAuthChoice.ALLOW_ONCE)
// Both waiters get the one answer. If the second had NOT been deduped it would have
// surfaced its own unanswered prompt and timed out to DISMISS — so this proves dedup.
assertEquals(UserAuthChoice.ALLOW_ONCE, first.await())
assertEquals(UserAuthChoice.ALLOW_ONCE, second.await())
}
@Test
fun unansweredPromptTimesOutToDismiss() =
runTest {
val bus = RelayAuthPromptBus(timeoutMs = 1_000L)
// No one ever responds; the call must not hang, it resolves to DISMISS.
assertEquals(UserAuthChoice.DISMISS, bus.requestDecision(relay, emptyList()))
}
@Test
fun retainsPromptForALateSubscriberSoItIsNotLost() =
runTest {
val bus = RelayAuthPromptBus()
// A challenge fires while NO host is collecting (cold start / account switch).
val caller = async { bus.requestDecision(relay, emptyList()) }
runCurrent() // let the emit happen with no subscriber present
// A host subscribes late; the replayed prompt must still reach it (not be lost, which
// would strand the caller until the timeout and then silently DISMISS).
bus.prompts.first().respond(UserAuthChoice.ALLOW_ONCE)
assertEquals(UserAuthChoice.ALLOW_ONCE, caller.await())
}
}
@@ -0,0 +1,143 @@
/*
* 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.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent
import org.junit.Assert.assertEquals
import org.junit.Test
class RelayAuthPurposeDeriverTest {
private val alice = "a".repeat(64)
private val bob = "b".repeat(64)
private fun event(
kind: Int,
pTags: List<String> = emptyList(),
) = Event(
id = "00".repeat(32),
pubKey = "11".repeat(32),
createdAt = 1_700_000_000L,
kind = kind,
tags = pTags.map { arrayOf("p", it) }.toTypedArray(),
content = "",
sig = "22".repeat(64),
)
@Test
fun giftWrapBecomesSendDmToItsRecipient() {
val purposes = RelayAuthPurposeDeriver.derive(listOf(event(GiftWrapEvent.KIND, listOf(alice))), emptyMap())
assertEquals(1, purposes.size)
assertEquals(AuthPurposeKind.SEND_DM, purposes[0].kind)
assertEquals(setOf(alice), purposes[0].counterparties)
}
@Test
fun nonGiftWrapWithPTagsBecomesNotifyInbox() {
val purposes = RelayAuthPurposeDeriver.derive(listOf(event(1, listOf(alice, bob))), emptyMap())
assertEquals(1, purposes.size)
assertEquals(AuthPurposeKind.NOTIFY_INBOX, purposes[0].kind)
assertEquals(setOf(alice, bob), purposes[0].counterparties)
}
@Test
fun notifyExcludesTheEventsOwnAuthor() {
val author = "11".repeat(32) // matches event()'s pubKey
val purposes = RelayAuthPurposeDeriver.derive(listOf(event(1, listOf(author, alice))), emptyMap())
assertEquals(1, purposes.size)
assertEquals(AuthPurposeKind.NOTIFY_INBOX, purposes[0].kind)
assertEquals(setOf(alice), purposes[0].counterparties)
}
@Test
fun subscriptionAuthorsBecomeReadOutbox() {
val purposes = RelayAuthPurposeDeriver.derive(emptyList(), mapOf("sub1" to listOf(Filter(authors = listOf(alice, bob)))))
assertEquals(1, purposes.size)
assertEquals(AuthPurposeKind.READ_OUTBOX, purposes[0].kind)
assertEquals(setOf(alice, bob), purposes[0].counterparties)
}
@Test
fun mixedPendingWorkYieldsAllPurposes() {
val purposes =
RelayAuthPurposeDeriver.derive(
pendingEvents = listOf(event(GiftWrapEvent.KIND, listOf(alice)), event(1, listOf(bob))),
activeFilters = mapOf("sub1" to listOf(Filter(authors = listOf(alice)))),
)
assertEquals(
setOf(AuthPurposeKind.SEND_DM, AuthPurposeKind.NOTIFY_INBOX, AuthPurposeKind.READ_OUTBOX),
purposes.map { it.kind }.toSet(),
)
}
@Test
fun noActivityYieldsNoPurposes() {
assertEquals(emptyList<AuthPurpose>(), RelayAuthPurposeDeriver.derive(emptyList(), 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)
}
}
@@ -0,0 +1,86 @@
/*
* 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.commons.relayauth
/**
* Why Amethyst is about to authenticate (NIP-42) with a relay. Carried to the decision
* point so we can (a) tell the user *why* an auth is requested and (b) apply follow-based
* trust against the counterparties a relay serves.
*
* Counterparty pubkeys are stored as hex strings to keep this module free of protocol types;
* display names/avatars are resolved elsewhere at render time.
*/
enum class AuthPurposeKind {
/** Delivering a NIP-17 private message to a recipient's DM inbox (kind 10050). */
SEND_DM,
/** Delivering a public reply/mention/reaction to a recipient's NIP-65 inbox (read relays). */
NOTIFY_INBOX,
/** 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. [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. */
data class RelayAuthContext(
val relayUrl: String,
val purposes: List<AuthPurpose> = emptyList(),
)
/**
* The runtime verdict for an auth challenge. Distinct from [RelayAuthDecision], which is the
* two-value ([RelayAuthDecision.ALLOW]/[RelayAuthDecision.DENY]) *persisted* per-relay override:
* [ASK] is only ever a live decision, never stored.
*/
enum class RelayAuthVerdict {
/** Sign and send the NIP-42 auth event. */
ALLOW,
/** Do not auth; do not reveal identity. */
DENY,
/** Prompt the user, explaining the purpose, before deciding. */
ASK,
}
@@ -40,4 +40,27 @@ interface RelayAuthPermissionStore {
/** All per-relay overrides — for the relay auth settings screen. */
suspend fun allDecisions(): Map<String, RelayAuthDecision>
/**
* Records *why* [relayUrl] was authenticated with, so the settings screen can explain each
* relay ("To send DMs to: …", "To download posts from: …"). [additions] maps a purpose to the
* counterparty pubkeys seen for it; implementations merge into whatever is already stored.
* Default no-op for stores that don't track rationale.
*/
suspend fun recordUse(
relayUrl: String,
additions: Map<AuthPurposeKind, Set<String>>,
) {}
/** The accumulated grant rationale for [relayUrl] (purpose → counterparty pubkeys). */
suspend fun loadRationale(relayUrl: String): Map<AuthPurposeKind, Set<String>> = emptyMap()
/** All per-relay rationales — for the relay auth settings screen. */
suspend fun allRationales(): Map<String, Map<AuthPurposeKind, Set<String>>> = emptyMap()
/** Forgets the accumulated grant rationale for [relayUrl] (does not touch the ALLOW/DENY override). */
suspend fun clearRationale(relayUrl: String) {}
/** Epoch-second timestamp of the last time each relay was authenticated with (for display). */
suspend fun allLastUsed(): Map<String, Long> = emptyMap()
}
@@ -21,18 +21,22 @@
package com.vitorpamplona.amethyst.commons.relayauth
/**
* The default policy for authenticating with relays (NIP-42).
* The top-level mode for authenticating with relays (NIP-42).
* Per-relay overrides stored in [RelayAuthPermissionStore] always take precedence.
*/
enum class RelayAuthPolicy {
/** Authenticate with every relay that requests it. Equivalent to current behavior. */
/** Authenticate with every relay that requests it. */
ALWAYS,
/** Never authenticate; do not reveal your identity to relay operators via NIP-42. */
NEVER,
/** Authenticate only with relays explicitly listed in the user's relay list. */
IF_IN_MY_LIST,
/**
* Apply the per-situation [RelayAuthCustomToggles]: authenticate only for the categories the
* user turned on (own relays/venues, reading or messaging follows, messaging strangers).
* Situations no toggle covers fall through to an explicit prompt ([RelayAuthVerdict.ASK]).
*/
CUSTOM,
}
/** A persisted per-relay override decision. */
@@ -0,0 +1,114 @@
/*
* 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.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 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).
* @param messageStrangers a relay serving the inbox of someone you *don't* follow. Off by default
* sending to a stranger otherwise prompts.
*/
data class RelayAuthCustomToggles(
val myRelaysAndVenues: Boolean = true,
val readFollows: Boolean = true,
val messageFollows: Boolean = true,
val messageStrangers: Boolean = false,
)
/**
* Everything the resolver needs to decide an auth challenge, gathered by the host (which owns
* the blocked-relay list, the user's relay lists, and the follow graph). Kept as plain values
* so the decision itself is pure and unit-testable without any account/relay wiring.
*
* @param storedOverride an explicit per-relay decision the user set previously, or null.
* @param isBlocked the relay is on the user's blocked-relay list (kind 10006).
* @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 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).
* @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.
*/
data class RelayAuthInputs(
val storedOverride: RelayAuthDecision?,
val isBlocked: Boolean,
val policy: RelayAuthPolicy,
val toggles: RelayAuthCustomToggles,
val isInMyRelayList: Boolean,
val servesTrustedVenue: Boolean,
val servesFollowedReadCounterparty: Boolean,
val servesFollowedWriteCounterparty: Boolean,
val servesStrangerWriteCounterparty: Boolean,
val hasAttributablePurpose: Boolean,
)
/**
* Pure NIP-42 auth decision. Precedence, highest first:
*
* 1. Blocked-relay list [RelayAuthVerdict.DENY] (never reveal identity to a blocked relay).
* 2. Explicit per-relay override honor it.
* 3. Top-level [RelayAuthPolicy]:
* - [RelayAuthPolicy.NEVER] DENY
* - [RelayAuthPolicy.ALWAYS] ALLOW
* - [RelayAuthPolicy.CUSTOM] ALLOW if any *enabled* [RelayAuthCustomToggles] category matches
* this relay (own relays/venues, reading follows, messaging follows, messaging strangers);
* else fall through
* 4. Fall-through [RelayAuthVerdict.ASK] when the purpose is known, otherwise DENY.
*/
object RelayAuthResolver {
fun resolve(inputs: RelayAuthInputs): RelayAuthVerdict {
if (inputs.isBlocked) return RelayAuthVerdict.DENY
inputs.storedOverride?.let {
return when (it) {
RelayAuthDecision.ALLOW -> RelayAuthVerdict.ALLOW
RelayAuthDecision.DENY -> RelayAuthVerdict.DENY
}
}
return when (inputs.policy) {
RelayAuthPolicy.NEVER -> RelayAuthVerdict.DENY
RelayAuthPolicy.ALWAYS -> RelayAuthVerdict.ALLOW
RelayAuthPolicy.CUSTOM ->
if (customAllows(inputs)) RelayAuthVerdict.ALLOW else fallThrough(inputs)
}
}
private fun customAllows(inputs: RelayAuthInputs): Boolean {
val t = inputs.toggles
return (t.myRelaysAndVenues && (inputs.isInMyRelayList || inputs.servesTrustedVenue)) ||
(t.readFollows && inputs.servesFollowedReadCounterparty) ||
(t.messageFollows && inputs.servesFollowedWriteCounterparty) ||
(t.messageStrangers && inputs.servesStrangerWriteCounterparty)
}
private fun fallThrough(inputs: RelayAuthInputs): RelayAuthVerdict = if (inputs.hasAttributablePurpose) RelayAuthVerdict.ASK else RelayAuthVerdict.DENY
}
@@ -0,0 +1,133 @@
/*
* 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.commons.relayauth
import kotlin.test.Test
import kotlin.test.assertEquals
class RelayAuthResolverTest {
private fun inputs(
storedOverride: RelayAuthDecision? = null,
isBlocked: Boolean = false,
policy: RelayAuthPolicy = RelayAuthPolicy.CUSTOM,
toggles: RelayAuthCustomToggles = RelayAuthCustomToggles(),
isInMyRelayList: Boolean = false,
servesTrustedVenue: Boolean = false,
servesFollowedReadCounterparty: Boolean = false,
servesFollowedWriteCounterparty: Boolean = false,
servesStrangerWriteCounterparty: Boolean = false,
hasAttributablePurpose: Boolean = true,
) = RelayAuthInputs(
storedOverride = storedOverride,
isBlocked = isBlocked,
policy = policy,
toggles = toggles,
isInMyRelayList = isInMyRelayList,
servesTrustedVenue = servesTrustedVenue,
servesFollowedReadCounterparty = servesFollowedReadCounterparty,
servesFollowedWriteCounterparty = servesFollowedWriteCounterparty,
servesStrangerWriteCounterparty = servesStrangerWriteCounterparty,
hasAttributablePurpose = hasAttributablePurpose,
)
private fun resolve(inputs: RelayAuthInputs) = RelayAuthResolver.resolve(inputs)
@Test
fun blockedRelayAlwaysDeniesEvenWithAllowOverrideAndAlwaysPolicy() {
assertEquals(
RelayAuthVerdict.DENY,
resolve(
inputs(
isBlocked = true,
storedOverride = RelayAuthDecision.ALLOW,
policy = RelayAuthPolicy.ALWAYS,
),
),
)
}
@Test
fun explicitOverrideBeatsPolicy() {
assertEquals(RelayAuthVerdict.DENY, resolve(inputs(storedOverride = RelayAuthDecision.DENY, policy = RelayAuthPolicy.ALWAYS)))
assertEquals(RelayAuthVerdict.ALLOW, resolve(inputs(storedOverride = RelayAuthDecision.ALLOW, policy = RelayAuthPolicy.NEVER)))
}
@Test
fun neverAndAlwaysAreUnconditional() {
assertEquals(RelayAuthVerdict.DENY, resolve(inputs(policy = RelayAuthPolicy.NEVER, isInMyRelayList = true)))
assertEquals(RelayAuthVerdict.ALLOW, resolve(inputs(policy = RelayAuthPolicy.ALWAYS, hasAttributablePurpose = false)))
}
@Test
fun customMyRelaysAndVenuesToggleGatesOwnRelaysAndVenues() {
// On (default): my own relay and any joined venue auto-auth.
assertEquals(RelayAuthVerdict.ALLOW, resolve(inputs(isInMyRelayList = true)))
assertEquals(RelayAuthVerdict.ALLOW, resolve(inputs(servesTrustedVenue = true)))
// Off: even my own relay prompts.
val off = RelayAuthCustomToggles(myRelaysAndVenues = false)
assertEquals(RelayAuthVerdict.ASK, resolve(inputs(isInMyRelayList = true, toggles = off)))
assertEquals(RelayAuthVerdict.ASK, resolve(inputs(servesTrustedVenue = true, toggles = off)))
}
@Test
fun customReadFollowsToggleGatesReadingFollows() {
assertEquals(RelayAuthVerdict.ALLOW, resolve(inputs(servesFollowedReadCounterparty = true)))
assertEquals(
RelayAuthVerdict.ASK,
resolve(inputs(servesFollowedReadCounterparty = true, toggles = RelayAuthCustomToggles(readFollows = false))),
)
}
@Test
fun customMessageFollowsToggleGatesMessagingFollows() {
assertEquals(RelayAuthVerdict.ALLOW, resolve(inputs(servesFollowedWriteCounterparty = true)))
assertEquals(
RelayAuthVerdict.ASK,
resolve(inputs(servesFollowedWriteCounterparty = true, toggles = RelayAuthCustomToggles(messageFollows = false))),
)
}
@Test
fun customMessageStrangersIsOffByDefault() {
// Default off: messaging a stranger prompts...
assertEquals(RelayAuthVerdict.ASK, resolve(inputs(servesStrangerWriteCounterparty = true)))
// ...on: auto-auth.
assertEquals(
RelayAuthVerdict.ALLOW,
resolve(inputs(servesStrangerWriteCounterparty = true, toggles = RelayAuthCustomToggles(messageStrangers = true))),
)
}
@Test
fun customHasNoToggleForReadingStrangers() {
// Reading a non-followed author (no matching category) always prompts, even with every
// toggle on — there is deliberately no "read strangers" trust category.
val allOn = RelayAuthCustomToggles(myRelaysAndVenues = true, readFollows = true, messageFollows = true, messageStrangers = true)
assertEquals(RelayAuthVerdict.ASK, resolve(inputs(toggles = allOn, hasAttributablePurpose = true)))
}
@Test
fun customFallsThroughForUncoveredSituation() {
// Nothing matches -> prompt when we know why, else silent deny.
assertEquals(RelayAuthVerdict.ASK, resolve(inputs(hasAttributablePurpose = true)))
assertEquals(RelayAuthVerdict.DENY, resolve(inputs(hasAttributablePurpose = false)))
}
}
@@ -127,6 +127,8 @@ class CoordinatorPipelineTest {
override fun activeCounts(url: NormalizedRelayUrl): Map<String, List<Filter>> = emptyMap()
override fun activeOutboxCache(url: NormalizedRelayUrl): Set<HexKey> = emptySet()
override fun activeOutboxEvents(url: NormalizedRelayUrl): List<Event> = emptyList()
}
private fun createCoordinator(
@@ -0,0 +1,163 @@
/*
* 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.geode
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.auth.RelayAuthenticator
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.relay.server.policies.FullAuthPolicy
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync
import com.vitorpamplona.quartz.nip01Core.tags.people.PTag
import com.vitorpamplona.quartz.nip17Dm.NIP17Factory
import com.vitorpamplona.quartz.nip17Dm.messages.ChatMessageEvent
import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withTimeoutOrNull
import kotlin.test.AfterTest
import kotlin.test.BeforeTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNull
/**
* End-to-end proof that a NIP-17 DM actually delivers to an auth-required relay through the
* [com.vitorpamplona.quartz.nip01Core.relay.client.pool.PoolEventOutbox] retry queue the exact
* path Amethyst's auth-permission feature depends on.
*
* The scenario mirrors "send a DM to someone whose inbox relay demands NIP-42 auth":
* 1. The relay runs [FullAuthPolicy], so it rejects every EVENT with `auth-required:` until the
* connection authenticates.
* 2. The client publishes the gift wrap immediately before AUTH so the first EVENT is rejected.
* 3. A [RelayAuthenticator] answers the challenge; the relay's OK-true triggers a filter/outbox
* resync and the still-pending wrap is resent and stored.
*
* Because the first EVENT is rejected with `auth-required` (not a hard failure), the outbox must
* NOT burn the wrap's retry budget on it otherwise the post-AUTH resync would have nothing left
* to resend. This test is the integration counterpart to PoolEventOutboxAuthTest's unit coverage.
*/
class Nip42AuthDmDeliveryTest {
private lateinit var hub: InProcessRelays
private lateinit var scope: CoroutineScope
private lateinit var client: NostrClient
private val relayUrl: NormalizedRelayUrl = InProcessRelays.DEFAULT_URL
@BeforeTest
fun setup() {
// Every connection to this relay demands NIP-42 auth before it will accept an EVENT.
hub = InProcessRelays(defaultPolicy = { FullAuthPolicy(relayUrl) })
scope = CoroutineScope(Dispatchers.Default + SupervisorJob())
client = NostrClient(hub, scope)
}
@AfterTest
fun teardown() {
client.disconnect()
scope.cancel()
hub.close()
}
/** The recipient's gift wrap (kind 1059) out of a freshly built NIP-17 DM. */
private suspend fun buildDmWrapTo(
sender: NostrSigner,
recipient: String,
): GiftWrapEvent {
val template = ChatMessageEvent.build("gm, this is private", listOf(PTag(recipient)))
val result = NIP17Factory().createMessageNIP17(template, sender)
return result.wraps.first { it.recipientPubKey() == recipient }
}
private suspend fun storedGiftWrapCount(): Int = hub.get(relayUrl)?.store?.count(Filter(kinds = listOf(GiftWrapEvent.KIND))) ?: 0
@Test
fun dmDeliversToAuthRequiredRelayAfterAuth() =
runBlocking {
val senderKeys = KeyPair()
val recipient = NostrSignerInternal(KeyPair()).pubKey
val wrap = buildDmWrapTo(NostrSignerInternal(senderKeys), recipient)
// The signer answers the relay's AUTH challenge on this client's behalf (same identity
// as the DM sender, though FullAuthPolicy would accept any authenticated pubkey).
val authSigner = NostrSignerSync(senderKeys)
val authenticator =
RelayAuthenticator(client = client, scope = scope) { _, template ->
listOf(authSigner.sign(template))
}
try {
// Publish goes through the outbox and races ahead of AUTH, so the first EVENT is
// rejected `auth-required`; the authenticator then unlocks the relay and the outbox
// resends the still-pending wrap.
client.publish(wrap, setOf(relayUrl))
val delivered =
withTimeoutOrNull(10_000) {
while (storedGiftWrapCount() < 1) delay(50)
true
}
assertEquals(true, delivered, "the DM gift wrap must land after AUTH resolves")
assertEquals(
wrap.id,
hub
.get(relayUrl)
?.store
?.query<Event>(Filter(kinds = listOf(GiftWrapEvent.KIND)))
?.single()
?.id,
"the stored event must be exactly the published wrap",
)
} finally {
authenticator.destroy()
}
}
@Test
fun dmIsRejectedByAuthRequiredRelayWithoutAuth() =
runBlocking {
// Control: with no authenticator wired, the relay never unlocks and the wrap is never
// stored — proving the relay genuinely gates on auth (so the test above isn't vacuous).
val recipient = NostrSignerInternal(KeyPair()).pubKey
val wrap = buildDmWrapTo(NostrSignerInternal(KeyPair()), recipient)
client.publish(wrap, setOf(relayUrl))
// Give the client ample time to connect, get rejected, and (wrongly) retry.
delay(2_000)
assertEquals(0, storedGiftWrapCount(), "an unauthenticated EVENT must never be stored")
assertNull(
hub
.get(relayUrl)
?.store
?.query<Event>(Filter(kinds = listOf(GiftWrapEvent.KIND)))
?.firstOrNull(),
)
}
}
@@ -104,6 +104,9 @@ interface INostrClient : AutoCloseable {
fun activeCounts(url: NormalizedRelayUrl): Map<String, List<Filter>>
fun activeOutboxCache(url: NormalizedRelayUrl): Set<HexKey>
/** The events still pending delivery to [url] (full events, not just ids). */
fun activeOutboxEvents(url: NormalizedRelayUrl): List<Event>
}
class EmptyNostrClient : INostrClient {
@@ -158,5 +161,7 @@ class EmptyNostrClient : INostrClient {
override fun activeOutboxCache(url: NormalizedRelayUrl): Set<HexKey> = emptySet()
override fun activeOutboxEvents(url: NormalizedRelayUrl): List<Event> = emptyList()
override fun close() {}
}
@@ -303,7 +303,9 @@ class NostrClient(
if (success) {
activeRequests.onSent(relay.url, cmd)
activeCounts.onSent(relay.url, cmd)
eventOutbox.onSent(relay.url, cmd)
eventOutbox.onSent(relay.url, cmd)?.let { gaveUp ->
listeners.forEach { it.onEventGaveUp(relay, gaveUp) }
}
}
listeners.forEach { it.onSent(relay, cmdStr, cmd, success) }
}
@@ -369,6 +371,8 @@ class NostrClient(
override fun activeOutboxCache(url: NormalizedRelayUrl): Set<HexKey> = eventOutbox.activeOutboxCacheFor(url)
override fun activeOutboxEvents(url: NormalizedRelayUrl): List<Event> = eventOutbox.activeOutboxEventsFor(url)
override fun pendingPublishRelaysFor(eventId: HexKey): Set<NormalizedRelayUrl>? = eventOutbox.pendingRelaysFor(eventId)
override fun getReqFiltersOrNull(subId: String): Map<NormalizedRelayUrl, List<Filter>>? = activeRequests.getSubscriptionFiltersOrNull(subId)
@@ -20,6 +20,7 @@
*/
package com.vitorpamplona.quartz.nip01Core.relay.client.listeners
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.relay.client.single.IRelayClient
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.Message
import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.Command
@@ -70,6 +71,16 @@ interface RelayConnectionListener {
relay: IRelayClient,
errorMessage: String,
) {}
/**
* The client exhausted its retry budget for [event] on [relay] and dropped it without a
* confirmation. Use this to surface a failed delivery instead of losing it silently; the
* event may still be pending on other relays.
*/
fun onEventGaveUp(
relay: IRelayClient,
event: Event,
) {}
}
object EmptyConnectionListener : RelayConnectionListener
@@ -96,6 +96,21 @@ class PoolEventOutbox {
return myEvents
}
/**
* The events still pending delivery to [url]. Unlike [activeOutboxCacheFor] (ids only), this
* returns the full events so callers can inspect kind/tags e.g. to explain *why* a relay is
* being authenticated with (a pending gift wrap => sending a DM to its recipient).
*/
fun activeOutboxEventsFor(url: NormalizedRelayUrl): List<Event> {
val myEvents = mutableListOf<Event>()
eventOutbox.forEach { (_, outboxCache) ->
if (url in outboxCache.relaysRemaining) {
myEvents.add(outboxCache.event)
}
}
return myEvents
}
/**
* Returns the relays that have NOT yet acknowledged [eventId] with an OK, or
* null if the event is not currently tracked (never sent or already fully done).
@@ -118,16 +133,19 @@ class PoolEventOutbox {
return eventOutbox[event.id]?.remainingRelays() ?: emptySet()
}
/** Records a send attempt. Returns the event if this attempt exhausted its retry budget for
* [url] (i.e. we gave up delivering it there), or null otherwise. */
fun newTry(
id: HexKey,
url: NormalizedRelayUrl,
) {
val waiting = eventOutbox[id]
waiting?.newTry(url)
if (waiting?.isDone() == true) {
): Event? {
val waiting = eventOutbox[id] ?: return null
val gaveUp = waiting.newTry(url)
if (waiting.isDone()) {
eventOutbox = eventOutbox - waiting.event.id
updateRelays()
}
return if (gaveUp) waiting.event else null
}
fun newResponse(
@@ -169,14 +187,11 @@ class PoolEventOutbox {
}
}
/** Returns the event if this send attempt made us give up delivering it to [relay]. */
fun onSent(
relay: NormalizedRelayUrl,
cmd: Command,
) {
if (cmd is EventCmd) {
newTry(cmd.event.id, relay)
}
}
): Event? = if (cmd is EventCmd) newTry(cmd.event.id, relay) else null
fun sendToRelayIfChanged(
event: Event,
@@ -48,17 +48,21 @@ class PoolEventOutboxState(
fun remainingRelays() = relaysRemaining
fun newTry(url: NormalizedRelayUrl) {
/** Records a send attempt to [url]. Returns true if the retry budget is now exhausted and the
* relay was dropped (i.e. we gave up delivering this event to [url]). */
fun newTry(url: NormalizedRelayUrl): Boolean {
val currentTries = failures[url]
if (currentTries != null) {
currentTries.addTriedTime(TimeUtils.now())
if (currentTries.isDone()) {
relaysRemaining = relaysRemaining - url
failures = failures - url
return true
}
} else {
failures = failures + (url to Tries(listOf(TimeUtils.now())))
}
return false
}
fun newResponse(
@@ -70,9 +74,15 @@ class PoolEventOutboxState(
relaysRemaining = relaysRemaining - url
failures = failures - url
} else if (message.isAuthRequired()) {
// NIP-42 AUTH challenge in flight — don't count toward the try cap.
// RelayAuthenticator signs + relay re-issues OK; syncFilters() then
// re-pumps this outbox so the original publish is retried.
// NIP-42 AUTH challenge in flight. The relay responded, so it's up and simply wants
// auth first: RelayAuthenticator signs, the relay re-issues OK, and syncFilters()
// re-pumps this outbox to retry the publish. Reset the retry budget for this relay
// (clear both tries and responses) so the send attempts accumulated across reconnects /
// slow AUTH rounds can't exhaust the cap and drop the event before AUTH lands. Note
// newTry() (the send path) grows `tries` and is NOT auth-aware, so only clearing
// `responses` would still let a re-pumped event give up here. relaysRemaining is left
// as-is: the event must stay pending for this relay until AUTH unlocks it.
failures = failures - url
} else {
val currentTries = failures[url]
if (currentTries != null) {
@@ -95,7 +105,12 @@ class PoolEventOutboxState(
this.startsWith("deleted:") ||
this.startsWith("invalid:")
fun String.isAuthRequired() = this.startsWith("auth-required:")
/**
* NIP-42 machine-readable prefix a relay uses to tell us an event was held
* back pending authentication. The event should be re-sent after AUTH, not
* retried-then-discarded like an ordinary failure.
*/
fun String.isAuthRequired() = this.startsWith("auth-required:") || this == "auth-required"
// Tries 3 times
class Tries(
@@ -0,0 +1,170 @@
/*
* 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.quartz.nip01Core.relay.client.pool
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.OkMessage
import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.Command
import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.EventCmd
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import kotlinx.coroutines.test.runTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNull
import kotlin.test.assertTrue
/**
* Regression coverage for NIP-42 handling in the outgoing-event retry queue.
*
* An `auth-required` NAK must be treated as "deferred pending AUTH", never as a
* retry that eats the [PoolEventOutboxState.Tries] budget otherwise a relay
* that NAKs every unauthenticated EVENT would drop the message before the AUTH
* handshake completes.
*/
class PoolEventOutboxAuthTest {
private val relay = NormalizedRelayUrl("wss://auth.relay.test")
private fun event(id: String) =
Event(
id = id,
pubKey = "00".repeat(32),
createdAt = 1_700_000_000L,
kind = 1,
tags = emptyArray(),
content = "hello",
sig = "00".repeat(64),
)
private fun PoolEventOutbox.publish(
event: Event,
relays: Set<NormalizedRelayUrl>,
) {
markAsSending(event, relays)
// simulate the actual EVENT frame going out on the wire
relays.forEach { onSent(it, EventCmd(event)) }
}
private fun PoolEventOutbox.nak(
event: Event,
relay: NormalizedRelayUrl,
message: String,
) = onIncomingMessage(relay, OkMessage(event.id, false, message))
private fun PoolEventOutbox.ok(
event: Event,
relay: NormalizedRelayUrl,
) = onIncomingMessage(relay, OkMessage(event.id, true, ""))
@Test
fun authRequiredSurvivesResendAfterAuth() =
runTest {
val outbox = PoolEventOutbox()
val ev = event("aa".repeat(32))
outbox.publish(ev, setOf(relay))
// Relay NAKs every unauthed EVENT with auth-required, more times than the
// 3-response retry budget. These must NOT be recorded as failures, or the
// post-auth resend below would trip Tries.isDone() and drop the event.
repeat(3) { outbox.nak(ev, relay, "auth-required: we can't serve unauthed writes") }
assertEquals(setOf(relay), outbox.pendingRelaysFor(ev.id))
// AUTH completes -> syncFilters re-sends the still-pending event.
val resent = mutableListOf<Command>()
outbox.syncState(relay) { resent.add(it) }
assertEquals(1, resent.size)
assertTrue(resent.first() is EventCmd)
// The resend records a new try. With the pre-fix behavior the poisoned
// budget would drop the event right here; it must still be pending.
outbox.onSent(relay, resent.first())
assertEquals(setOf(relay), outbox.pendingRelaysFor(ev.id))
// Relay now accepts the authenticated event.
outbox.ok(ev, relay)
assertNull(outbox.pendingRelaysFor(ev.id))
}
@Test
fun authRequiredResetsTheTriesBudgetAcrossManyResends() {
val outbox = PoolEventOutbox()
val ev = event("ff".repeat(32))
outbox.publish(ev, setOf(relay)) // first try
// A flapping relay / slow AUTH handshake re-pumps the still-pending event many more times
// than the 4-try cap, each NAK'd auth-required. newTry() (the send path) grows `tries` and
// is not auth-aware, so unless auth-required resets the retry budget these sends would trip
// Tries.isDone() and drop the event (with a spurious give-up) before AUTH ever lands.
repeat(8) { i ->
assertNull(outbox.onSent(relay, EventCmd(ev)), "must not give up on re-pump $i")
outbox.nak(ev, relay, "auth-required: authenticate first")
}
assertEquals(setOf(relay), outbox.pendingRelaysFor(ev.id))
// AUTH finally completes -> the event delivers.
outbox.ok(ev, relay)
assertNull(outbox.pendingRelaysFor(ev.id))
}
@Test
fun terminalRejectionStillDiscardsImmediately() {
val outbox = PoolEventOutbox()
val ev = event("cc".repeat(32))
outbox.publish(ev, setOf(relay))
outbox.nak(ev, relay, "invalid: bad signature")
assertNull(outbox.pendingRelaysFor(ev.id))
}
@Test
fun givesUpAndSignalsAfterExhaustingTryBudget() {
val outbox = PoolEventOutbox()
val ev = event("ee".repeat(32))
// markAsSending + first onSent (1 try). Tries budget is >3 tries.
outbox.publish(ev, setOf(relay))
// attempts 2, 3 stay under budget and signal nothing.
assertNull(outbox.onSent(relay, EventCmd(ev)))
assertNull(outbox.onSent(relay, EventCmd(ev)))
// the 4th attempt exhausts the budget -> event is returned (gave up) and dropped.
assertEquals(ev.id, outbox.onSent(relay, EventCmd(ev))?.id)
assertNull(outbox.pendingRelaysFor(ev.id))
}
@Test
fun ordinaryTransientFailureStillBounded() {
val outbox = PoolEventOutbox()
val ev = event("dd".repeat(32))
outbox.publish(ev, setOf(relay))
// 3 non-auth error responses exhaust the retry budget. Responses only
// accumulate here; the drop happens on the next send attempt.
repeat(3) { outbox.nak(ev, relay, "error: rate-limited") }
assertEquals(setOf(relay), outbox.pendingRelaysFor(ev.id))
// The next resend attempt observes the exhausted budget and drops the event
// (unlike auth-required, which never poisons the budget).
outbox.onSent(relay, EventCmd(ev))
assertNull(outbox.pendingRelaysFor(ev.id))
}
}
@@ -104,6 +104,8 @@ private class TrackingNostrClient : INostrClient {
override fun activeOutboxCache(url: NormalizedRelayUrl): Set<String> = emptySet()
override fun activeOutboxEvents(url: NormalizedRelayUrl): List<Event> = emptyList()
override fun close() {}
}
@@ -431,6 +431,8 @@ private class CapturingNostrClient : INostrClient {
override fun activeOutboxCache(url: NormalizedRelayUrl): Set<HexKey> = emptySet()
override fun activeOutboxEvents(url: NormalizedRelayUrl): List<Event> = emptyList()
override fun close() {}
}
@@ -490,5 +492,7 @@ private class CountingNostrClient(
override fun activeOutboxCache(url: NormalizedRelayUrl): Set<HexKey> = emptySet()
override fun activeOutboxEvents(url: NormalizedRelayUrl): List<Event> = emptyList()
override fun close() {}
}