mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-10 08:27:04 +00:00
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:
@@ -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
|
||||
|
||||
+28
-2
@@ -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
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
+63
@@ -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
|
||||
}
|
||||
+371
@@ -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()
|
||||
}
|
||||
+29
-14
@@ -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 {
|
||||
|
||||
+68
@@ -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)
|
||||
}
|
||||
}
|
||||
+122
-3
@@ -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())
|
||||
|
||||
+65
-16
@@ -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]. */
|
||||
|
||||
+119
@@ -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
|
||||
}
|
||||
}
|
||||
+117
@@ -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)
|
||||
|
||||
|
||||
+334
-133
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
+117
@@ -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)
|
||||
}
|
||||
}
|
||||
+177
@@ -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],
|
||||
)
|
||||
}
|
||||
}
|
||||
+117
@@ -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))
|
||||
}
|
||||
}
|
||||
+87
@@ -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())
|
||||
}
|
||||
}
|
||||
+143
@@ -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)
|
||||
}
|
||||
}
|
||||
+86
@@ -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,
|
||||
}
|
||||
+23
@@ -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()
|
||||
}
|
||||
|
||||
+8
-4
@@ -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. */
|
||||
|
||||
+114
@@ -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
|
||||
}
|
||||
+133
@@ -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)))
|
||||
}
|
||||
}
|
||||
Vendored
+2
@@ -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(),
|
||||
)
|
||||
}
|
||||
}
|
||||
+5
@@ -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() {}
|
||||
}
|
||||
|
||||
+5
-1
@@ -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)
|
||||
|
||||
+11
@@ -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
|
||||
|
||||
+24
-9
@@ -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,
|
||||
|
||||
+20
-5
@@ -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(
|
||||
|
||||
+170
@@ -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))
|
||||
}
|
||||
}
|
||||
+2
@@ -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() {}
|
||||
}
|
||||
|
||||
|
||||
+4
@@ -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() {}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user