mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-06 03:38:23 +00:00
Merge pull request #3952 from vitorpamplona/claude/relay-auth-permission-bug-zesbwe
Make the "reading someone I follow" relay-auth toggle reachable, and scope Buzz per-account state to the account
This commit is contained in:
@@ -51,6 +51,12 @@ Shipped as designed. Where it diverged or went further:
|
||||
connection forever. A second challenge for the same (relay, account) rides along
|
||||
on the owner's answer with no deadline of its own — running one would let it
|
||||
resolve the shared deferred and tear down a dialog mid-read.
|
||||
- **Corrected later:** this plan left the decision model alone, including the
|
||||
blanket `isFirstParty` gate on `CUSTOM`. That gate turned out to make
|
||||
`readFollows` ("…I'm reading someone I follow") unreachable — a follow's outbox
|
||||
relay is theirs, so it is never first-party for us, and every follow produced a
|
||||
prompt with the toggle explicitly on. `RelayAuthResolver.customAllows` now
|
||||
checks that one category ahead of the gate; the other three still require it.
|
||||
- **Still not done:** what a timeout should *look like*. It is now an honest 60s of
|
||||
visible time rather than a clock the user never saw, but it is still a dialog
|
||||
that vanishes and an event left pending in the outbox with no feedback. That
|
||||
|
||||
@@ -287,18 +287,6 @@ class AppModules(
|
||||
}
|
||||
}
|
||||
|
||||
// Restore + persist held NIP-OA attestations across restarts (device-global). Eager (not
|
||||
// lazy) so it loads before the first Buzz-relay AUTH and mirrors later changes to disk.
|
||||
val buzzAttestationPrefs = BuzzAttestationPreferences(appContext, applicationIOScope)
|
||||
|
||||
// Restore + persist the joined Buzz workspace relays across restarts (device-global). Eager so
|
||||
// the app knows which relays to sync as workspaces on cold start (Buzz membership is
|
||||
// server-side; there is no join event to rebuild the set from).
|
||||
val buzzWorkspacePrefs = BuzzWorkspacePreferences(appContext, applicationIOScope)
|
||||
|
||||
// Restore + persist the user's starred Buzz workspace channels across restarts (device-global).
|
||||
val buzzChannelStarPrefs = BuzzChannelStarPreferences(appContext, applicationIOScope)
|
||||
|
||||
// Restore + persist the set of relay-group channels deleted (kind-9008) on this device, so a
|
||||
// deleted channel stays hidden across a restart even if the host relay re-announces a stale
|
||||
// kind-44100 for it (device-global; a delete is authoritative and terminal for everyone).
|
||||
@@ -905,6 +893,17 @@ class AppModules(
|
||||
meterSigner = { MeteringNostrSigner(it, resourceUsage) },
|
||||
signerPermissionStore = signerPermissionStore,
|
||||
nip46ClientStore = nip46ClientStore,
|
||||
// Restore + persist the Buzz bookkeeping that has no Nostr event to rebuild from: the
|
||||
// joined workspace relays (so the app knows which relays to sync as workspaces on cold
|
||||
// start — Buzz membership is server-side) and the starred channels. Per account: the
|
||||
// joined set makes a relay first-party for NIP-42, and a star is personal.
|
||||
startBuzzPersistence = { account ->
|
||||
BuzzWorkspacePreferences(appContext, account.scope, account.pubKey, account.buzzWorkspaces)
|
||||
BuzzChannelStarPreferences(appContext, account.scope, account.pubKey, account.buzzChannelStars)
|
||||
// Eager like the rest, so a held NIP-OA attestation is loaded before this account's
|
||||
// first Buzz-relay AUTH rather than after it.
|
||||
BuzzAttestationPreferences(appContext, account.scope, account.pubKey, account.buzzAttestation)
|
||||
},
|
||||
)
|
||||
|
||||
val sessionManager =
|
||||
|
||||
@@ -34,7 +34,10 @@ import com.vitorpamplona.amethyst.commons.defaults.Constants
|
||||
import com.vitorpamplona.amethyst.commons.defaults.DefaultIndexerRelayList
|
||||
import com.vitorpamplona.amethyst.commons.marmot.MarmotManager
|
||||
import com.vitorpamplona.amethyst.commons.model.IAccount
|
||||
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzChannelStars
|
||||
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzHeldAttestations
|
||||
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzRelayDialect
|
||||
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzWorkspaces
|
||||
import com.vitorpamplona.amethyst.commons.model.concord.ConcordChannel
|
||||
import com.vitorpamplona.amethyst.commons.model.concord.ConcordChannelListState
|
||||
import com.vitorpamplona.amethyst.commons.model.concord.ConcordSessionManager
|
||||
@@ -407,6 +410,23 @@ class Account(
|
||||
// answered without a disk read. Backed by a per-account file (see AccountCacheState).
|
||||
val relayAuthPermissions = RelayAuthPermissionCache(relayAuthPermissionStore, scope)
|
||||
|
||||
// The `block/buzz` workspaces THIS account joined. Per account, not per device: the invite was
|
||||
// redeemed by this key and the relay grants membership to it alone — and this set makes the
|
||||
// relay first-party for NIP-42 (see AuthCoordinator.isFirstParty), so a device-global set would
|
||||
// hand every other logged-in account an automatic login on a workspace it never joined.
|
||||
// Restored/persisted per account by BuzzWorkspacePreferences (see AccountCacheState).
|
||||
val buzzWorkspaces = BuzzWorkspaces()
|
||||
|
||||
// The Buzz channels THIS account pinned. A star says which channels this user wants at the top
|
||||
// of the community view, so a shared set let one account reorder and badge every other one's
|
||||
// channel list. Restored/persisted per account by BuzzChannelStarPreferences.
|
||||
val buzzChannelStars = BuzzChannelStars()
|
||||
|
||||
// The NIP-OA attestation an owner issued to THIS account's key, attached to its Buzz-relay
|
||||
// AUTH so the relay grants virtual membership. Restored/persisted per account by
|
||||
// BuzzAttestationPreferences.
|
||||
val buzzAttestation = BuzzHeldAttestations(pubKey)
|
||||
|
||||
// Per-account NIP-42 policy evaluator (blocked → per-relay override → global policy → prompt),
|
||||
// reading THIS account's own toggles, relay lists and follow graph. Cached here so every AUTH
|
||||
// path (foreground screen + background notification consumer) shares one instance, and so an
|
||||
@@ -562,6 +582,7 @@ class Account(
|
||||
ChannelInvitesState(
|
||||
me = signer.pubKey,
|
||||
cache = cache,
|
||||
buzzWorkspaces = buzzWorkspaces,
|
||||
relayGroupList = relayGroupList,
|
||||
dismissed = settings.dismissedChannelInvites,
|
||||
scope = scope,
|
||||
|
||||
+11
@@ -73,6 +73,13 @@ class AccountCacheState(
|
||||
val signerPermissionStore: NostrSignerPermissionStore = InMemoryNostrSignerPermissionStore(),
|
||||
/** App-global store of connected NIP-46 client display + relay info. */
|
||||
val nip46ClientStore: Nip46ClientStore = InMemoryNip46ClientStore(),
|
||||
/**
|
||||
* Starts per-account persistence of the Buzz client-side bookkeeping that has no Nostr event to
|
||||
* rebuild from — the joined workspaces and the starred channels (restore now, mirror later
|
||||
* changes). A lambda because those stores need an Android `Context` and this class deliberately
|
||||
* takes none; no-op by default so tests and non-Android hosts build an Account without it.
|
||||
*/
|
||||
val startBuzzPersistence: (Account) -> Unit = { },
|
||||
) {
|
||||
val accounts = MutableStateFlow<Map<HexKey, Account>>(emptyMap())
|
||||
|
||||
@@ -286,6 +293,10 @@ class AccountCacheState(
|
||||
signerPermissionStore = signerPermissionStore,
|
||||
nip46ClientStore = nip46ClientStore,
|
||||
).also { newAccount ->
|
||||
// Per account, not per device: the joined set makes a relay first-party for NIP-42, so a
|
||||
// shared one hands every other logged-in account an automatic login on a workspace it
|
||||
// never joined, and a shared star set reorders everyone's channel list at once.
|
||||
startBuzzPersistence(newAccount)
|
||||
accounts.update { existingAccounts ->
|
||||
existingAccounts.plus(Pair(signer.pubKey, newAccount))
|
||||
}
|
||||
|
||||
+12
-8
@@ -20,7 +20,6 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.model.buzz
|
||||
|
||||
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzWorkspaces
|
||||
import com.vitorpamplona.amethyst.commons.model.buzz.ChannelClassification
|
||||
import com.vitorpamplona.amethyst.commons.model.buzz.MembershipNotice
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
@@ -62,17 +61,19 @@ fun membershipNoticeFilter(me: HexKey) =
|
||||
* the relay that issued it — the channel UUID it names is that relay's. So prefer a relay we joined as a
|
||||
* workspace; fall back to whatever else delivered it, which keeps a notice usable when the workspace set
|
||||
* hasn't been restored from disk yet.
|
||||
*
|
||||
* [workspaces] is passed in rather than read from a singleton because the joined set is per account
|
||||
* (`Account.buzzWorkspaces`): whose workspaces to prefer is a question only the caller can answer.
|
||||
*/
|
||||
private fun Note.membershipRelay(): NormalizedRelayUrl? {
|
||||
private fun Note.membershipRelay(workspaces: Set<NormalizedRelayUrl>): NormalizedRelayUrl? {
|
||||
val seen = relays
|
||||
if (seen.isEmpty()) return null
|
||||
val workspaces = BuzzWorkspaces.flow.value
|
||||
return seen.firstOrNull { it in workspaces } ?: seen.first()
|
||||
}
|
||||
|
||||
/** Flattens a cached kind-44100/44101 into a [MembershipNotice], or null when it isn't usable. */
|
||||
fun Note.toMembershipNotice(): MembershipNotice? {
|
||||
val relay = membershipRelay() ?: return null
|
||||
fun Note.toMembershipNotice(workspaces: Set<NormalizedRelayUrl>): MembershipNotice? {
|
||||
val relay = membershipRelay(workspaces) ?: return null
|
||||
return when (val noteEvent = event) {
|
||||
is MemberAddedNotificationEvent ->
|
||||
noteEvent.channel()?.let {
|
||||
@@ -88,7 +89,7 @@ fun Note.toMembershipNotice(): MembershipNotice? {
|
||||
}
|
||||
}
|
||||
|
||||
fun List<Note>.toMembershipNotices(): List<MembershipNotice> = mapNotNull { it.toMembershipNotice() }
|
||||
fun List<Note>.toMembershipNotices(workspaces: Set<NormalizedRelayUrl>): List<MembershipNotice> = mapNotNull { it.toMembershipNotice(workspaces) }
|
||||
|
||||
private fun Note.isMembershipNoticeFor(me: HexKey): Boolean =
|
||||
when (val noteEvent = event) {
|
||||
@@ -112,11 +113,14 @@ private fun Note.isMembershipNoticeFor(me: HexKey): Boolean =
|
||||
* So the observer is kept purely as the change signal and this scan is the data. It is the same shape
|
||||
* `NotificationFeedFilter.feed()` uses over the same map, for the same reason.
|
||||
*/
|
||||
fun LocalCache.membershipNotices(me: HexKey): List<MembershipNotice> =
|
||||
fun LocalCache.membershipNotices(
|
||||
me: HexKey,
|
||||
workspaces: Set<NormalizedRelayUrl>,
|
||||
): List<MembershipNotice> =
|
||||
notes
|
||||
.filterIntoSet { _, note -> note.isMembershipNoticeFor(me) }
|
||||
.toList()
|
||||
.toMembershipNotices()
|
||||
.toMembershipNotices(workspaces)
|
||||
|
||||
/**
|
||||
* The Buzz type of every channel whose kind-39000 the cache already holds, keyed by group id.
|
||||
|
||||
@@ -66,6 +66,7 @@ import kotlinx.coroutines.flow.stateIn
|
||||
class ChannelInvitesState(
|
||||
private val me: HexKey,
|
||||
private val cache: LocalCache,
|
||||
private val buzzWorkspaces: BuzzWorkspaces,
|
||||
relayGroupList: RelayGroupListState,
|
||||
dismissed: StateFlow<Set<String>>,
|
||||
scope: CoroutineScope,
|
||||
@@ -108,8 +109,8 @@ class ChannelInvitesState(
|
||||
private val notices =
|
||||
combine(
|
||||
cache.observeNotes(membershipNoticeFilter(me)),
|
||||
BuzzWorkspaces.flow,
|
||||
) { _, _ -> cache.membershipNotices(me) }
|
||||
buzzWorkspaces.flow,
|
||||
) { _, workspaces -> cache.membershipNotices(me, workspaces) }
|
||||
|
||||
/** Pending invites keyed by the kind-44100 that produced them — what the notifications DAL reads. */
|
||||
val pendingByEventId: StateFlow<Map<HexKey, BuzzChannelInvite>> =
|
||||
|
||||
+72
-26
@@ -37,25 +37,36 @@ import kotlinx.serialization.json.Json
|
||||
import kotlin.coroutines.cancellation.CancellationException
|
||||
|
||||
/**
|
||||
* Device-global persistence for the NIP-OA attestations this device holds
|
||||
* ([BuzzHeldAttestations]), so a held credential survives an app restart instead of
|
||||
* needing to be re-pasted. Uses the app-wide [sharedPreferencesDataStore] like
|
||||
* [NamecoinSharedPreferences] (not per-account — the store is already keyed by the agent
|
||||
* pubkey each attestation authorizes).
|
||||
* Per-account persistence for the NIP-OA attestation this account holds
|
||||
* ([BuzzHeldAttestations]), so a held credential survives an app restart instead of needing to be
|
||||
* re-pasted. The key is namespaced by pubkey; the store used to be one device-global list because
|
||||
* each entry carried the agent key it authorized, which made the file a per-account store with
|
||||
* extra steps.
|
||||
*
|
||||
* On construction it loads the saved entries into the singleton — **re-verifying each
|
||||
* against its agent key**, so a tampered on-disk credential is dropped rather than trusted
|
||||
* — then mirrors every later change back to disk. Construct once, eagerly, at startup.
|
||||
* On construction it loads this account's saved attestation and mirrors every later change back to
|
||||
* disk. Re-verification on restore is no longer done here: [BuzzHeldAttestations.put] verifies
|
||||
* against the agent key itself and rejects what fails, so a tampered on-disk credential is dropped
|
||||
* by the same gate that rejects a mistyped one. Construct once per account, eagerly.
|
||||
*/
|
||||
@Stable
|
||||
class BuzzAttestationPreferences(
|
||||
private val context: Context,
|
||||
private val scope: CoroutineScope,
|
||||
private val pubKeyHex: HexKey,
|
||||
private val attestation: BuzzHeldAttestations,
|
||||
) {
|
||||
private val json = Json { ignoreUnknownKeys = true }
|
||||
private val key = stringPreferencesKey("$KEY_PREFIX$pubKeyHex")
|
||||
|
||||
@Serializable
|
||||
private data class Entry(
|
||||
val owner: HexKey,
|
||||
val conditions: String,
|
||||
val sig: HexKey,
|
||||
)
|
||||
|
||||
/** The pre-namespacing on-disk shape: one list for the whole device, each entry agent-keyed. */
|
||||
@Serializable
|
||||
private data class LegacyEntry(
|
||||
val agent: HexKey,
|
||||
val owner: HexKey,
|
||||
val conditions: String,
|
||||
@@ -67,41 +78,76 @@ class BuzzAttestationPreferences(
|
||||
restoreFromDisk()
|
||||
// Persist on every change AFTER the initial restore (drop(1) skips the value
|
||||
// present at collection start, which restoreFromDisk already wrote).
|
||||
BuzzHeldAttestations.flow.drop(1).collect { persist(it) }
|
||||
attestation.flow.drop(1).collect { persist(it) }
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun restoreFromDisk() {
|
||||
try {
|
||||
val raw = context.sharedPreferencesDataStore.data.first()[KEY] ?: return
|
||||
val verified =
|
||||
json
|
||||
.decodeFromString<List<Entry>>(raw)
|
||||
.mapNotNull { e ->
|
||||
val attestation = OwnerAttestation(e.owner, e.conditions, e.sig)
|
||||
// Only reinstate a credential that still verifies for its agent key.
|
||||
if (attestation.verify(e.agent)) e.agent to attestation else null
|
||||
}.toMap()
|
||||
if (verified.isNotEmpty()) BuzzHeldAttestations.restore(verified)
|
||||
val prefs = context.sharedPreferencesDataStore.data.first()
|
||||
// put() verifies, so a credential that no longer checks out is dropped either way.
|
||||
restoreFrom(prefs[key], prefs[LEGACY_KEY], pubKeyHex)?.let(attestation::put)
|
||||
} catch (e: Exception) {
|
||||
if (e is CancellationException) throw e
|
||||
Log.e("BuzzAttestationPrefs") { "Error reading held attestations: ${e.message}" }
|
||||
Log.e("BuzzAttestationPrefs") { "Error reading held attestation: ${e.message}" }
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun persist(entries: Map<HexKey, OwnerAttestation>) {
|
||||
private suspend fun persist(held: OwnerAttestation?) {
|
||||
try {
|
||||
val list = entries.map { (agent, a) -> Entry(agent, a.ownerPubKey, a.conditions, a.sig) }
|
||||
context.sharedPreferencesDataStore.edit { prefs ->
|
||||
prefs[KEY] = json.encodeToString(list)
|
||||
// Write [NONE] rather than removing the key: removing it is indistinguishable from
|
||||
// never having migrated, which would let the legacy list re-seed a credential the
|
||||
// user just deleted. See [restoreFrom].
|
||||
prefs[key] = if (held == null) NONE else json.encodeToString(Entry(held.ownerPubKey, held.conditions, held.sig))
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
if (e is CancellationException) throw e
|
||||
Log.e("BuzzAttestationPrefs") { "Error writing held attestations: ${e.message}" }
|
||||
Log.e("BuzzAttestationPrefs") { "Error writing held attestation: ${e.message}" }
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val KEY = stringPreferencesKey("buzz.heldAttestations")
|
||||
private const val KEY_PREFIX = "buzz.heldAttestation."
|
||||
|
||||
/** The device-global key written before the store became per-account; read-only now. */
|
||||
private val LEGACY_KEY = stringPreferencesKey("buzz.heldAttestations")
|
||||
|
||||
/**
|
||||
* Tombstone for "this account has been migrated and holds nothing", which an *absent* key
|
||||
* cannot express — absent still means "never migrated" and is allowed to seed from
|
||||
* [LEGACY_KEY]. Without it, removing a held attestation lasted only until the next launch,
|
||||
* because nothing ever clears the legacy list. (The starred-channel and joined-workspace
|
||||
* stores get this for free: they persist an empty *set*, which reads back present.)
|
||||
*/
|
||||
private const val NONE = ""
|
||||
|
||||
private val json = Json { ignoreUnknownKeys = true }
|
||||
|
||||
/**
|
||||
* Which attestation to reinstate, given this account's saved value and the pre-namespacing
|
||||
* device-global list. Pure, so the migration precedence is testable without a `Context`.
|
||||
*
|
||||
* [saved] wins whenever it is present, [NONE] included. Only a never-migrated account falls
|
||||
* back to [legacy], and it takes just the entry issued to its own key — that list was
|
||||
* already agent-keyed, so no other account's credential can match. Nothing is verified here;
|
||||
* [BuzzHeldAttestations.put] is the gate that rejects a tampered credential.
|
||||
*/
|
||||
internal fun restoreFrom(
|
||||
saved: String?,
|
||||
legacy: String?,
|
||||
agentPubKey: HexKey,
|
||||
): OwnerAttestation? {
|
||||
if (saved != null) {
|
||||
if (saved == NONE) return null
|
||||
val entry = json.decodeFromString<Entry>(saved)
|
||||
return OwnerAttestation(entry.owner, entry.conditions, entry.sig)
|
||||
}
|
||||
val list = legacy ?: return null
|
||||
return json
|
||||
.decodeFromString<List<LegacyEntry>>(list)
|
||||
.firstOrNull { it.agent == agentPubKey }
|
||||
?.let { OwnerAttestation(it.owner, it.conditions, it.sig) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+27
-9
@@ -25,6 +25,7 @@ import androidx.compose.runtime.Stable
|
||||
import androidx.datastore.preferences.core.edit
|
||||
import androidx.datastore.preferences.core.stringSetPreferencesKey
|
||||
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzChannelStars
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.utils.Log
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.flow.drop
|
||||
@@ -33,28 +34,39 @@ import kotlinx.coroutines.launch
|
||||
import kotlin.coroutines.cancellation.CancellationException
|
||||
|
||||
/**
|
||||
* Device-global persistence for the set of starred Buzz workspace channels ([BuzzChannelStars]),
|
||||
* so favorites survive a restart. Mirrors [BuzzWorkspacePreferences]: app-wide (not per-account),
|
||||
* loads the saved ids into the singleton on construction, then writes every later change back.
|
||||
* Construct once, eagerly.
|
||||
* Per-account persistence for the set of starred Buzz workspace channels ([BuzzChannelStars]), so
|
||||
* favorites survive a restart. Mirrors [BuzzWorkspacePreferences] in both shape and reasoning: the
|
||||
* key is namespaced by pubkey because a star is personal — it says which channels *this* user wants
|
||||
* pinned — and one device-global set meant one account's favorites reordered and badged every other
|
||||
* logged-in account's channel list. Loads this account's saved ids into [stars] on construction,
|
||||
* then writes every later change back. Construct once per account, eagerly.
|
||||
*/
|
||||
@Stable
|
||||
class BuzzChannelStarPreferences(
|
||||
private val context: Context,
|
||||
private val scope: CoroutineScope,
|
||||
private val pubKeyHex: HexKey,
|
||||
private val stars: BuzzChannelStars,
|
||||
) {
|
||||
private val key = stringSetPreferencesKey("$KEY_PREFIX$pubKeyHex")
|
||||
|
||||
init {
|
||||
scope.launch {
|
||||
restoreFromDisk()
|
||||
// drop(1) skips the value present at collection start, which restoreFromDisk already wrote.
|
||||
BuzzChannelStars.flow.drop(1).collect { persist(it) }
|
||||
stars.flow.drop(1).collect { persist(it) }
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun restoreFromDisk() {
|
||||
try {
|
||||
val raw = context.sharedPreferencesDataStore.data.first()[KEY] ?: return
|
||||
if (raw.isNotEmpty()) BuzzChannelStars.restore(raw)
|
||||
val prefs = context.sharedPreferencesDataStore.data.first()
|
||||
// Fall back to the pre-namespacing device-global key so an upgrade doesn't unpin
|
||||
// everything. That set is what every account already saw; the next toggle writes to this
|
||||
// account's own key and takes over. The legacy key is left for other accounts to seed
|
||||
// from and is never written again.
|
||||
val raw = prefs[key] ?: prefs[LEGACY_KEY] ?: return
|
||||
if (raw.isNotEmpty()) stars.restore(raw)
|
||||
} catch (e: Exception) {
|
||||
if (e is CancellationException) throw e
|
||||
Log.e("BuzzChannelStarPrefs") { "Error reading starred channels: ${e.message}" }
|
||||
@@ -63,7 +75,10 @@ class BuzzChannelStarPreferences(
|
||||
|
||||
private suspend fun persist(ids: Set<String>) {
|
||||
try {
|
||||
context.sharedPreferencesDataStore.edit { prefs -> prefs[KEY] = ids }
|
||||
// Always write the starred set, empty included — never remove the key. An absent key
|
||||
// means "never migrated" and re-seeds from the legacy one above, so removing it
|
||||
// would undo the user's last removal on the next launch.
|
||||
context.sharedPreferencesDataStore.edit { prefs -> prefs[key] = ids }
|
||||
} catch (e: Exception) {
|
||||
if (e is CancellationException) throw e
|
||||
Log.e("BuzzChannelStarPrefs") { "Error writing starred channels: ${e.message}" }
|
||||
@@ -71,6 +86,9 @@ class BuzzChannelStarPreferences(
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val KEY = stringSetPreferencesKey("buzz.starredChannels")
|
||||
private const val KEY_PREFIX = "buzz.starredChannels."
|
||||
|
||||
/** The device-global key written before the set became per-account; read-only now. */
|
||||
private val LEGACY_KEY = stringSetPreferencesKey("buzz.starredChannels")
|
||||
}
|
||||
}
|
||||
|
||||
+41
-14
@@ -25,6 +25,7 @@ import androidx.compose.runtime.Stable
|
||||
import androidx.datastore.preferences.core.edit
|
||||
import androidx.datastore.preferences.core.stringSetPreferencesKey
|
||||
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzWorkspaces
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
|
||||
import com.vitorpamplona.quartz.utils.Log
|
||||
@@ -35,36 +36,56 @@ import kotlinx.coroutines.launch
|
||||
import kotlin.coroutines.cancellation.CancellationException
|
||||
|
||||
/**
|
||||
* Device-global persistence for the set of joined `block/buzz` workspaces ([BuzzWorkspaces]),
|
||||
* so the app knows which relays to connect + NIP-42-authenticate + run member-channel discovery
|
||||
* against on a cold start — Buzz membership is server-side (granted by the HTTP invite claim),
|
||||
* with no NIP-51/kind-10009 join event to rebuild the set from. Uses the app-wide
|
||||
* [sharedPreferencesDataStore] like [BuzzAttestationPreferences] (not per-account: a joined
|
||||
* relay is workspace-wide, and restoring only marks relays to sync — the relay still gates every
|
||||
* read/write by the authenticated key).
|
||||
* Per-account persistence for the set of joined `block/buzz` workspaces ([BuzzWorkspaces]), so the
|
||||
* app knows which relays to connect + NIP-42-authenticate + run member-channel discovery against on
|
||||
* a cold start — Buzz membership is server-side (granted by the HTTP invite claim), with no
|
||||
* NIP-51/kind-10009 join event to rebuild the set from.
|
||||
*
|
||||
* On construction it loads the saved relay URLs into the singleton (re-normalizing each, dropping
|
||||
* any that no longer parse), then mirrors every later change back to disk. Construct once, eagerly.
|
||||
* **Per account, not per device.** The set used to be one device-global key shared by every logged-in
|
||||
* account, on the reasoning that restoring only marks relays to sync and the relay gates each
|
||||
* read/write by the authenticated key anyway. That missed one consumer: the joined set also makes a
|
||||
* relay first-party in `AuthCoordinator.isFirstParty`, so one account joining a workspace silently
|
||||
* gave *every* other logged-in account an automatic NIP-42 login there — the bystander-account leak
|
||||
* the per-account gate exists to prevent. The key is namespaced by pubkey for the same reason the
|
||||
* relay-auth overrides moved to a per-account file.
|
||||
*
|
||||
* Still on the app-wide [sharedPreferencesDataStore] file — the namespacing, not the file, is what
|
||||
* separates accounts, and one file avoids a second DataStore per logged-in account.
|
||||
*
|
||||
* On construction it loads this account's saved relay URLs into [workspaces] (re-normalizing each,
|
||||
* dropping any that no longer parse), then mirrors every later change back to disk. Construct once
|
||||
* per account, eagerly.
|
||||
*/
|
||||
@Stable
|
||||
class BuzzWorkspacePreferences(
|
||||
private val context: Context,
|
||||
private val scope: CoroutineScope,
|
||||
private val pubKeyHex: HexKey,
|
||||
private val workspaces: BuzzWorkspaces,
|
||||
) {
|
||||
private val key = stringSetPreferencesKey("$KEY_PREFIX$pubKeyHex")
|
||||
|
||||
init {
|
||||
scope.launch {
|
||||
restoreFromDisk()
|
||||
// Persist on every change AFTER the initial restore (drop(1) skips the value present
|
||||
// at collection start, which restoreFromDisk already wrote).
|
||||
BuzzWorkspaces.flow.drop(1).collect { persist(it) }
|
||||
workspaces.flow.drop(1).collect { persist(it) }
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun restoreFromDisk() {
|
||||
try {
|
||||
val raw = context.sharedPreferencesDataStore.data.first()[KEY] ?: return
|
||||
val prefs = context.sharedPreferencesDataStore.data.first()
|
||||
// Fall back to the pre-namespacing device-global key so an upgrade doesn't empty the
|
||||
// workspaces hub. That set is whatever any account joined, which is exactly what every
|
||||
// account already saw before this became per-account — so seeding from it changes
|
||||
// nothing that was true yesterday, and the first join after the upgrade writes to this
|
||||
// account's own key and takes over. The legacy key is left in place for the other
|
||||
// accounts to seed from; nothing writes it again.
|
||||
val raw = prefs[key] ?: prefs[LEGACY_KEY] ?: return
|
||||
val relays = raw.mapNotNull { RelayUrlNormalizer.normalizeOrNull(it) }.toSet()
|
||||
if (relays.isNotEmpty()) BuzzWorkspaces.restore(relays)
|
||||
if (relays.isNotEmpty()) workspaces.restore(relays)
|
||||
} catch (e: Exception) {
|
||||
if (e is CancellationException) throw e
|
||||
Log.e("BuzzWorkspacePrefs") { "Error reading joined workspaces: ${e.message}" }
|
||||
@@ -73,8 +94,11 @@ class BuzzWorkspacePreferences(
|
||||
|
||||
private suspend fun persist(relays: Set<NormalizedRelayUrl>) {
|
||||
try {
|
||||
// Always write the joined set, empty included — never remove the key. An absent key
|
||||
// means "never migrated" and re-seeds from the legacy one above, so removing it
|
||||
// would undo the user's last removal on the next launch.
|
||||
context.sharedPreferencesDataStore.edit { prefs ->
|
||||
prefs[KEY] = relays.map { it.url }.toSet()
|
||||
prefs[key] = relays.map { it.url }.toSet()
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
if (e is CancellationException) throw e
|
||||
@@ -83,6 +107,9 @@ class BuzzWorkspacePreferences(
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val KEY = stringSetPreferencesKey("buzz.joinedWorkspaces")
|
||||
private const val KEY_PREFIX = "buzz.joinedWorkspaces."
|
||||
|
||||
/** The device-global key written before the set became per-account; read-only now. */
|
||||
private val LEGACY_KEY = stringSetPreferencesKey("buzz.joinedWorkspaces")
|
||||
}
|
||||
}
|
||||
|
||||
+30
-21
@@ -21,9 +21,7 @@
|
||||
package com.vitorpamplona.amethyst.service.relayClient.authCommand.model
|
||||
|
||||
import androidx.compose.runtime.Stable
|
||||
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzHeldAttestations
|
||||
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzRelayDialect
|
||||
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzWorkspaces
|
||||
import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthContext
|
||||
import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthDecision
|
||||
import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthVerdict
|
||||
@@ -103,6 +101,10 @@ class AuthCoordinator(
|
||||
// question. Returning early here instead made "decide per relay" mean "deny, and
|
||||
// don't mention it" for every purpose that names someone else — the exact case the
|
||||
// prompt was built to explain.
|
||||
//
|
||||
// It does not reach the "…I'm reading someone I follow" toggle at all: a follow's
|
||||
// outbox relay can never be first-party for us, so applying it there emptied the
|
||||
// category instead of narrowing it. RelayAuthResolver.customAllows has the detail.
|
||||
val firstParty = isFirstParty(account, relayUrl)
|
||||
|
||||
val approve =
|
||||
@@ -115,8 +117,9 @@ class AuthCoordinator(
|
||||
// reveal @b — an answer is only about the identity it was shown for.
|
||||
// The bus still collapses concurrent challenges for the same
|
||||
// (relay, account) pair, which is the case the shared prompt was for.
|
||||
// In practice this rarely means two dialogs: isFirstParty already
|
||||
// drops every account without its own reason to be on this relay.
|
||||
// In practice this rarely means two dialogs: for everything except
|
||||
// reading a follow, isFirstParty already drops every account without
|
||||
// its own reason to be on this relay.
|
||||
//
|
||||
// But never block the derived stream-key AUTH behind that dialog: on a
|
||||
// relay that hosts our Concord planes we DISMISS the user-auth ASK
|
||||
@@ -158,7 +161,7 @@ class AuthCoordinator(
|
||||
// Remember why we granted this relay so the settings screen can explain it.
|
||||
account.relayAuthLedger.recordGrant(context)
|
||||
try {
|
||||
signed.add(account.signer.sign(buzzAugmented(authTemplate, account.pubKey, relayUrl)))
|
||||
signed.add(account.signer.sign(buzzAugmented(authTemplate, account, relayUrl)))
|
||||
} catch (e: Exception) {
|
||||
Log.e("AuthCoordinator", "Failed trying to authenticate a writeable account", e)
|
||||
}
|
||||
@@ -200,23 +203,23 @@ class AuthCoordinator(
|
||||
}
|
||||
|
||||
/**
|
||||
* If [relayUrl] speaks the Buzz dialect and this device holds a NIP-OA attestation
|
||||
* authorizing [accountPubKey], returns [template] with the owner-signed `auth` tag
|
||||
* appended — so the relay grants virtual membership to an un-enrolled agent key while
|
||||
* its owner stays a member. Otherwise returns [template] unchanged.
|
||||
* If [relayUrl] speaks the Buzz dialect and [account] holds a NIP-OA attestation authorizing
|
||||
* its key, returns [template] with the owner-signed `auth` tag appended — so the relay grants
|
||||
* virtual membership to an un-enrolled agent key while its owner stays a member. Otherwise
|
||||
* returns [template] unchanged.
|
||||
*
|
||||
* Applied ONLY to an account's own AUTH (the caller passes the account pubkey), never
|
||||
* to the Concord stream-key AUTHs that share the same [template] object, and it is a
|
||||
* no-op on non-Buzz relays and for accounts with no held attestation — so it can never
|
||||
* add an `auth` tag where one isn't wanted.
|
||||
* Applied ONLY to an account's own AUTH (the caller passes the account being signed for), never
|
||||
* to the Concord stream-key AUTHs that share the same [template] object, and it is a no-op on
|
||||
* non-Buzz relays and for accounts with no held attestation — so it can never add an `auth` tag
|
||||
* where one isn't wanted.
|
||||
*/
|
||||
private fun buzzAugmented(
|
||||
template: EventTemplate<RelayAuthEvent>,
|
||||
accountPubKey: HexKey,
|
||||
account: Account,
|
||||
relayUrl: NormalizedRelayUrl,
|
||||
): EventTemplate<RelayAuthEvent> {
|
||||
if (!BuzzRelayDialect.isBuzz(relayUrl)) return template
|
||||
val authTag = BuzzHeldAttestations.authTagFor(accountPubKey) ?: return template
|
||||
val authTag = account.buzzAttestation.authTag() ?: return template
|
||||
return EventTemplate(template.createdAt, template.kind, template.tags + arrayOf(authTag), template.content)
|
||||
}
|
||||
|
||||
@@ -231,18 +234,24 @@ class AuthCoordinator(
|
||||
* Merely *following* the counterparty of someone else's traffic is deliberately NOT first-party:
|
||||
* that is exactly how a bystander account got dragged into a paid inbox relay's AUTH (the shared
|
||||
* auth context carries the OTHER account's counterparties, evaluated against this account's
|
||||
* follow graph). Reads of a followed author's outbox on an auth-gated relay this account doesn't
|
||||
* use are therefore no longer auto-authed — a deliberate privacy-positive trade-off.
|
||||
* follow graph).
|
||||
*
|
||||
* Reading a followed author's outbox is the one case this cannot speak to. That relay is the
|
||||
* author's, so nothing here can ever return true for it, which is why
|
||||
* [com.vitorpamplona.amethyst.commons.relayauth.RelayAuthResolver] applies the
|
||||
* [com.vitorpamplona.amethyst.commons.relayauth.RelayAuthCustomToggles.readFollows] category
|
||||
* without consulting this — otherwise the toggle would be permanently off.
|
||||
*/
|
||||
private fun isFirstParty(
|
||||
account: Account,
|
||||
relayUrl: NormalizedRelayUrl,
|
||||
): Boolean =
|
||||
// A Buzz workspace the user explicitly joined is a first-party reason to authenticate: its
|
||||
// channel/DM discovery is read-only (`#p` = me), which is otherwise deliberately NOT
|
||||
// A Buzz workspace THIS account explicitly joined is a first-party reason to authenticate:
|
||||
// its channel/DM discovery is read-only (`#p` = me), which is otherwise deliberately NOT
|
||||
// first-party, so without this the p-gated 44100/30622 reads would never be served and the
|
||||
// workspace would stay empty.
|
||||
BuzzWorkspaces.isJoined(relayUrl) ||
|
||||
// workspace would stay empty. Read off the account, never a device-global set: the invite was
|
||||
// redeemed by one key, and a shared set made every other logged-in account first-party here.
|
||||
account.buzzWorkspaces.isJoined(relayUrl) ||
|
||||
RelayAuthFirstParty.hasReason(
|
||||
me = account.pubKey,
|
||||
relayUrl = relayUrl,
|
||||
|
||||
+2
-3
@@ -20,7 +20,6 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.buzz
|
||||
|
||||
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzWorkspaces
|
||||
import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter
|
||||
import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose
|
||||
import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthDecision
|
||||
@@ -130,7 +129,7 @@ class BuzzMembershipEoseManager(
|
||||
// whole membership history to know which channels I am in — and the EOSE map that would supply
|
||||
// a floor is in-memory too, so it is null exactly when the full read is needed. The filter is
|
||||
// `#p`-scoped to my own key, so an all-time query costs one index scan.
|
||||
return BuzzWorkspaces.flow.value.flatMap { relay ->
|
||||
return key.account.buzzWorkspaces.flow.value.flatMap { relay ->
|
||||
filterWorkspaceInboxToPubkey(relay, me, since?.get(relay)?.time)
|
||||
}
|
||||
}
|
||||
@@ -144,7 +143,7 @@ class BuzzMembershipEoseManager(
|
||||
userJobMap[user] =
|
||||
listOf(
|
||||
key.account.scope.launch(Dispatchers.IO) {
|
||||
BuzzWorkspaces.flow.collectLatest { relays ->
|
||||
key.account.buzzWorkspaces.flow.collectLatest { relays ->
|
||||
// Idempotent, and re-run per joined set rather than once at mount so a workspace
|
||||
// joined later is pre-approved too.
|
||||
relays.forEach { key.account.relayAuthLedger.setDecision(it.url, RelayAuthDecision.ALLOW) }
|
||||
|
||||
@@ -98,7 +98,8 @@ fun RenderChannelInvite(
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
val notice = remember(note) { note.toMembershipNotice() } ?: return
|
||||
val workspaces = accountViewModel.account.buzzWorkspaces.flow.value
|
||||
val notice = remember(note, workspaces) { note.toMembershipNotice(workspaces) } ?: return
|
||||
// A withdrawn membership is not an invite. The projection already excludes these before a card is
|
||||
// built; re-checked here because this renderer is reachable from any NoteCompose over a 44100.
|
||||
if (notice.removed) return
|
||||
|
||||
+24
-13
@@ -139,7 +139,7 @@ fun AgentAttestationScreen(
|
||||
// Agent side: hold an attestation an owner gave you, so this account
|
||||
// authenticates to the owner's Buzz relays as a virtual member. Available to
|
||||
// any signer — holding a credential doesn't require the raw key.
|
||||
HoldAttestationSection(myPubkey = myPubkey)
|
||||
HoldAttestationSection(myPubkey = myPubkey, attestation = accountViewModel.account.buzzAttestation)
|
||||
|
||||
// Owner side: issue an attestation for an agent key. Needs the raw private key.
|
||||
val privKey = keyPair.privKey
|
||||
@@ -153,15 +153,17 @@ fun AgentAttestationScreen(
|
||||
}
|
||||
|
||||
/**
|
||||
* Agent-side: paste an `auth` tag an owner issued to this account's key. It is verified
|
||||
* against [myPubkey] and, on success, stored in [BuzzHeldAttestations] so the auth
|
||||
* coordinator attaches it when this account AUTHs to a Buzz relay. Persisted across
|
||||
* restarts (device-global) by `BuzzAttestationPreferences`.
|
||||
* Agent-side: paste an `auth` tag an owner issued to this account's key. [parseHeldAttestation]
|
||||
* turns it into a typed failure the field can show, and [BuzzHeldAttestations.put] re-checks the
|
||||
* signature before storing, so the auth coordinator attaches it when this account AUTHs to a Buzz
|
||||
* relay. Persisted across restarts, per account, by `BuzzAttestationPreferences`.
|
||||
*/
|
||||
@Composable
|
||||
private fun HoldAttestationSection(myPubkey: String) {
|
||||
val held by BuzzHeldAttestations.flow.collectAsState()
|
||||
val mine = held[myPubkey]
|
||||
private fun HoldAttestationSection(
|
||||
myPubkey: String,
|
||||
attestation: BuzzHeldAttestations,
|
||||
) {
|
||||
val mine = attestation.flow.collectAsState().value
|
||||
|
||||
var input by remember { mutableStateOf("") }
|
||||
var error by remember { mutableStateOf<String?>(null) }
|
||||
@@ -183,7 +185,7 @@ private fun HoldAttestationSection(myPubkey: String) {
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
OutlinedButton(onClick = { BuzzHeldAttestations.remove(myPubkey) }) {
|
||||
OutlinedButton(onClick = { attestation.clear() }) {
|
||||
Text(stringRes(R.string.buzz_attest_remove))
|
||||
}
|
||||
} else {
|
||||
@@ -210,9 +212,15 @@ private fun HoldAttestationSection(myPubkey: String) {
|
||||
when (val outcome = parseHeldAttestation(input, myPubkey)) {
|
||||
is HoldOutcome.Failure -> error = outcome.message
|
||||
is HoldOutcome.Success -> {
|
||||
BuzzHeldAttestations.put(myPubkey, outcome.attestation)
|
||||
input = ""
|
||||
error = null
|
||||
// put() re-checks the signature, so honour its answer instead of
|
||||
// assuming it stored: clearing the field on a rejected paste would
|
||||
// read as success and leave the account holding nothing.
|
||||
if (attestation.put(outcome.attestation)) {
|
||||
input = ""
|
||||
error = null
|
||||
} else {
|
||||
error = NOT_FOR_THIS_ACCOUNT
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -236,6 +244,9 @@ private sealed interface HoldOutcome {
|
||||
) : HoldOutcome
|
||||
}
|
||||
|
||||
/** Shown for both rejection paths — the parse-time check and [BuzzHeldAttestations.put]'s. */
|
||||
private const val NOT_FOR_THIS_ACCOUNT = "This attestation does not authorize the current account, or its signature is invalid."
|
||||
|
||||
/**
|
||||
* Parses a pasted `["auth", owner, conditions, sig]` JSON array and verifies it
|
||||
* authorizes [myPubkey]. Returns a human-readable failure on malformed JSON, a
|
||||
@@ -259,7 +270,7 @@ private fun parseHeldAttestation(
|
||||
OwnerAttestation.parse(tag)
|
||||
?: return HoldOutcome.Failure("Not a NIP-OA auth tag.")
|
||||
if (!attestation.verify(myPubkey)) {
|
||||
return HoldOutcome.Failure("This attestation does not authorize the current account, or its signature is invalid.")
|
||||
return HoldOutcome.Failure(NOT_FOR_THIS_ACCOUNT)
|
||||
}
|
||||
return HoldOutcome.Success(attestation)
|
||||
}
|
||||
|
||||
+1
-2
@@ -26,7 +26,6 @@ import androidx.lifecycle.viewModelScope
|
||||
import com.vitorpamplona.amethyst.commons.model.buzz.AgentFleetAggregator
|
||||
import com.vitorpamplona.amethyst.commons.model.buzz.AgentFleetMetrics
|
||||
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzRelayDialect
|
||||
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzWorkspaces
|
||||
import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthDecision
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
@@ -107,7 +106,7 @@ class AgentConsoleViewModel : ViewModel() {
|
||||
this.scopeRelay = relay
|
||||
this.account = account
|
||||
relay?.let {
|
||||
val newlyJoined = BuzzWorkspaces.join(it)
|
||||
val newlyJoined = account.buzzWorkspaces.join(it)
|
||||
viewModelScope.launch { account.relayAuthLedger.setDecision(it.url, RelayAuthDecision.ALLOW) }
|
||||
if (newlyJoined) reconnectPoolAfterJoin(account.client)
|
||||
}
|
||||
|
||||
+14
-10
@@ -97,16 +97,20 @@ private suspend fun runBuzzDmDiscovery(account: Account) {
|
||||
.observeNotes(Filter(kinds = listOf(GroupMetadataEvent.KIND)))
|
||||
.map { buzzChannelTypes(it) }
|
||||
.distinctUntilChanged(),
|
||||
) { _, knownTypes -> BuzzChannelInvites.currentMemberships(LocalCache.membershipNotices(me)) to knownTypes }
|
||||
.collectLatest { (memberships, knownTypes) ->
|
||||
fetchMissingDirectories(account, memberships, knownTypes)
|
||||
BuzzDmChannels.replace(
|
||||
me,
|
||||
memberships.filter { (id, relay) ->
|
||||
classifyBuzzChannel(LocalCache, id, relay, knownTypes) == ChannelClassification.DM
|
||||
},
|
||||
)
|
||||
}
|
||||
) { _, knownTypes ->
|
||||
// Read the joined set per pass, not once: which relay vouched for a notice depends on it,
|
||||
// and restore-from-disk can land after the cache already holds notices.
|
||||
val workspaces = account.buzzWorkspaces.flow.value
|
||||
BuzzChannelInvites.currentMemberships(LocalCache.membershipNotices(me, workspaces)) to knownTypes
|
||||
}.collectLatest { (memberships, knownTypes) ->
|
||||
fetchMissingDirectories(account, memberships, knownTypes)
|
||||
BuzzDmChannels.replace(
|
||||
me,
|
||||
memberships.filter { (id, relay) ->
|
||||
classifyBuzzChannel(LocalCache, id, relay, knownTypes) == ChannelClassification.DM
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+13
-6
@@ -27,7 +27,6 @@ import com.vitorpamplona.amethyst.commons.model.buzz.BuzzChannelInvites
|
||||
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzDmChannels
|
||||
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzDmRegistry
|
||||
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzRelayDialect
|
||||
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzWorkspaces
|
||||
import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthDecision
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
@@ -113,7 +112,14 @@ class BuzzDmListViewModel : ViewModel() {
|
||||
val lastActivity: Long,
|
||||
)
|
||||
|
||||
private fun relays(): Set<NormalizedRelayUrl> = scopeRelay?.let { setOf(it) } ?: (BuzzWorkspaces.flow.value + BuzzRelayDialect.flow.value)
|
||||
private fun relays(): Set<NormalizedRelayUrl> =
|
||||
scopeRelay?.let { setOf(it) } ?: (
|
||||
account
|
||||
?.buzzWorkspaces
|
||||
?.flow
|
||||
?.value
|
||||
.orEmpty() + BuzzRelayDialect.flow.value
|
||||
)
|
||||
|
||||
/**
|
||||
* Binds to [account] scoped to the community [relayUrl]. Marks that relay a joined workspace and
|
||||
@@ -130,7 +136,7 @@ class BuzzDmListViewModel : ViewModel() {
|
||||
val relay = RelayUrlNormalizer.normalizeOrNull(relayUrl) ?: return
|
||||
this.scopeRelay = relay
|
||||
|
||||
val newlyJoined = BuzzWorkspaces.join(relay)
|
||||
val newlyJoined = account.buzzWorkspaces.join(relay)
|
||||
viewModelScope.launch { account.relayAuthLedger.setDecision(relay.url, RelayAuthDecision.ALLOW) }
|
||||
if (newlyJoined) reconnectPoolAfterJoin(account.client)
|
||||
|
||||
@@ -310,9 +316,10 @@ class BuzzDmListViewModel : ViewModel() {
|
||||
// Re-read the relay scope per pass rather than snapshotting it: a workspace
|
||||
// joined while this screen is open should bring its channels with it.
|
||||
val scoped = relays()
|
||||
val workspaces = account.buzzWorkspaces.flow.value
|
||||
val memberships =
|
||||
BuzzChannelInvites
|
||||
.currentMemberships(LocalCache.membershipNotices(myPubkey))
|
||||
.currentMemberships(LocalCache.membershipNotices(myPubkey, workspaces))
|
||||
.filterValues { it in scoped }
|
||||
var changed = false
|
||||
memberships.forEach { (channelId, relay) ->
|
||||
@@ -327,7 +334,7 @@ class BuzzDmListViewModel : ViewModel() {
|
||||
// would let one pass wipe rows nothing withdrew.
|
||||
val withdrawn =
|
||||
LocalCache
|
||||
.membershipNotices(myPubkey)
|
||||
.membershipNotices(myPubkey, workspaces)
|
||||
.let { BuzzChannelInvites.latestPerChannel(it) }
|
||||
.filterValues { it.removed }
|
||||
.keys
|
||||
@@ -344,7 +351,7 @@ class BuzzDmListViewModel : ViewModel() {
|
||||
}
|
||||
// Re-project when my hidden set (30622) or the joined-relay set changes.
|
||||
launch {
|
||||
combine(BuzzDmRegistry.hidden, BuzzWorkspaces.flow, BuzzRelayDialect.flow) { _, _, _ -> }
|
||||
combine(BuzzDmRegistry.hidden, account.buzzWorkspaces.flow, BuzzRelayDialect.flow) { _, _, _ -> }
|
||||
.collect { rebuildRows(account) }
|
||||
}
|
||||
}
|
||||
|
||||
+1
-2
@@ -55,7 +55,6 @@ import androidx.compose.ui.unit.dp
|
||||
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.buzz.BuzzWorkspaces
|
||||
import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthDecision
|
||||
import com.vitorpamplona.amethyst.favorites.FavoriteAppLauncher
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
||||
@@ -189,7 +188,7 @@ fun BuzzInviteScreen(
|
||||
// authenticates without a prompt, then hand off to the in-app window.nostr
|
||||
// browser to accept terms + sign the claim.
|
||||
RelayUrlNormalizer.normalizeOrNull(invite.relayUrl())?.let { relay ->
|
||||
BuzzWorkspaces.join(relay)
|
||||
accountViewModel.account.buzzWorkspaces.join(relay)
|
||||
scope.launch { accountViewModel.account.relayAuthLedger.setDecision(relay.url, RelayAuthDecision.ALLOW) }
|
||||
}
|
||||
FavoriteAppLauncher.launchUrl(context, link)
|
||||
|
||||
+1
-2
@@ -22,7 +22,6 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.buzz
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzWorkspaces
|
||||
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupDeletions
|
||||
import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthDecision
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
@@ -104,7 +103,7 @@ class BuzzRelayImportViewModel : ViewModel() {
|
||||
|
||||
// The user came here to import from THIS relay: remember it as a joined workspace (persisted,
|
||||
// marks the Buzz dialect) and pre-approve NIP-42 auth so the `#p=me` read below is served.
|
||||
val newlyJoined = BuzzWorkspaces.join(normalized)
|
||||
val newlyJoined = account.buzzWorkspaces.join(normalized)
|
||||
viewModelScope.launch { account.relayAuthLedger.setDecision(normalized.url, RelayAuthDecision.ALLOW) }
|
||||
|
||||
// Unlocks the persistent group-roster (39002) subscription — see [reconnectPoolAfterJoin].
|
||||
|
||||
+7
-5
@@ -39,16 +39,18 @@ import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.quartz.nip29RelayGroups.GroupId
|
||||
|
||||
/**
|
||||
* Pin/Unpin a Buzz channel (a device-local favorite — [BuzzChannelStars]). Moved off the per-channel
|
||||
* list row into the opened channel's/forum's top-bar overflow, so the list row stays a clean
|
||||
* tap-to-open target. Reads the live starred set so the label + icon reflect the current state.
|
||||
* Pin/Unpin a Buzz channel (a local favorite of this account — [BuzzChannelStars]). Moved off the
|
||||
* per-channel list row into the opened channel's/forum's top-bar overflow, so the list row stays a
|
||||
* clean tap-to-open target. Reads the live starred set so the label + icon reflect the current state.
|
||||
*/
|
||||
@Composable
|
||||
fun BuzzPinDropdownItem(
|
||||
groupId: GroupId,
|
||||
accountViewModel: AccountViewModel,
|
||||
closeMenu: () -> Unit,
|
||||
) {
|
||||
val starred by BuzzChannelStars.flow.collectAsStateWithLifecycle()
|
||||
val stars = accountViewModel.account.buzzChannelStars
|
||||
val starred by stars.flow.collectAsStateWithLifecycle()
|
||||
val isStarred = groupId.id in starred
|
||||
DropdownMenuItem(
|
||||
leadingIcon = {
|
||||
@@ -62,7 +64,7 @@ fun BuzzPinDropdownItem(
|
||||
text = { Text(stringRes(if (isStarred) R.string.buzz_unpin else R.string.buzz_pin)) },
|
||||
onClick = {
|
||||
closeMenu()
|
||||
BuzzChannelStars.toggle(groupId.id)
|
||||
stars.toggle(groupId.id)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
+2
-2
@@ -68,7 +68,6 @@ 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.Note
|
||||
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzChannelStars
|
||||
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzCommunityMembership
|
||||
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzRelayDialect
|
||||
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel
|
||||
@@ -289,7 +288,8 @@ fun RelayGroupChannelListScreen(
|
||||
// visibly reshuffled in the second after opening, and came back differently each visit. Ordering
|
||||
// by a property of the channel instead makes the first frame the final order; a channel whose
|
||||
// 39000 hasn't arrived sorts by its id until the name lands.
|
||||
val starred by BuzzChannelStars.flow.collectAsStateWithLifecycle()
|
||||
val starred by accountViewModel.account.buzzChannelStars.flow
|
||||
.collectAsStateWithLifecycle()
|
||||
|
||||
fun buzzSortKey(groupId: GroupId): String = channelsById[groupId.id]?.toBestDisplayName()?.lowercase() ?: groupId.id
|
||||
|
||||
|
||||
+1
-1
@@ -188,7 +188,7 @@ private fun RelayGroupThreads(
|
||||
)
|
||||
}
|
||||
DropdownMenu(expanded = menuOpen, onDismissRequest = { menuOpen = false }) {
|
||||
BuzzPinDropdownItem(channel.groupId) { menuOpen = false }
|
||||
BuzzPinDropdownItem(channel.groupId, accountViewModel) { menuOpen = false }
|
||||
RelayGroupMessagesDropdownItem(channel, accountViewModel) { menuOpen = false }
|
||||
if (isAdmin) {
|
||||
val archived = channel.isArchived()
|
||||
|
||||
+1
-1
@@ -255,7 +255,7 @@ fun RelayGroupTopBar(
|
||||
// Pin/Unpin moved here off the community-list row. A local favorite, so it's offered
|
||||
// for any Buzz channel/forum regardless of membership; DMs are never pinned.
|
||||
if (isBuzzRelay && !isDm) {
|
||||
BuzzPinDropdownItem(channel.groupId) { menuOpen = false }
|
||||
BuzzPinDropdownItem(channel.groupId, accountViewModel) { menuOpen = false }
|
||||
}
|
||||
// A DM's Add/Remove-from-Messages, moved off the DM list row. It rides the per-viewer
|
||||
// 30622 hide snapshot (kind-41012 hide / re-open), not the kind-10009 list, and is
|
||||
|
||||
+2
-1
@@ -644,7 +644,8 @@ private fun ChannelInviteRoomCompose(
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
val notice = remember(inviteNote) { inviteNote.toMembershipNotice() } ?: return
|
||||
val workspaces = accountViewModel.account.buzzWorkspaces.flow.value
|
||||
val notice = remember(inviteNote, workspaces) { inviteNote.toMembershipNotice(workspaces) } ?: return
|
||||
val baseChannel =
|
||||
remember(notice) { LocalCache.getOrCreateRelayGroupChannel(GroupId(notice.channelId, notice.relay)) }
|
||||
|
||||
|
||||
+1
-1
@@ -232,7 +232,7 @@ fun RelayAuthSettingsScreen(
|
||||
) {
|
||||
SettingsSwitchTile(
|
||||
icon = MaterialSymbols.Dns,
|
||||
title = R.string.relay_auth_auto_my_relays,
|
||||
title = R.string.relay_auth_auto_my_relays_and_venues,
|
||||
checked = myRelays,
|
||||
onCheckedChange = { account.settings.changeRelayAuthTrustMyRelaysAndVenues(it) },
|
||||
)
|
||||
|
||||
@@ -1170,7 +1170,6 @@
|
||||
keys are new rather than reused - a stale translation of the old standalone titles would
|
||||
read as a non-sequitur under this header. -->
|
||||
<string name="relay_auth_auto_login_when">Přihlásit se bez ptaní, když…</string>
|
||||
<string name="relay_auth_auto_my_relays">…jde o mé relé nebo o místnost, do které jsem vstoupil</string>
|
||||
<string name="relay_auth_auto_read_follows">…čtu někoho, koho sleduji</string>
|
||||
<string name="relay_auth_auto_message_follows">…píšu někomu, koho sleduji</string>
|
||||
<string name="relay_auth_auto_message_strangers">…píšu komukoli jinému</string>
|
||||
|
||||
@@ -1110,7 +1110,6 @@
|
||||
keys are new rather than reused - a stale translation of the old standalone titles would
|
||||
read as a non-sequitur under this header. -->
|
||||
<string name="relay_auth_auto_login_when">Ohne Nachfrage anmelden, wenn…</string>
|
||||
<string name="relay_auth_auto_my_relays">…es mein Relay ist oder ein Raum, dem ich beigetreten bin</string>
|
||||
<string name="relay_auth_auto_read_follows">…ich jemanden lese, dem ich folge</string>
|
||||
<string name="relay_auth_auto_message_follows">…ich jemandem schreibe, dem ich folge</string>
|
||||
<string name="relay_auth_auto_message_strangers">…ich jemand anderem schreibe</string>
|
||||
|
||||
@@ -1110,7 +1110,6 @@
|
||||
keys are new rather than reused - a stale translation of the old standalone titles would
|
||||
read as a non-sequitur under this header. -->
|
||||
<string name="relay_auth_auto_login_when">कब पूछे बिना प्रवेशांकन करें\u2026</string>
|
||||
<string name="relay_auth_auto_my_relays">\u2026यह मेरा पुनःप्रसारक है। अथवा एक शाला जिससे मैं जुड चुका</string>
|
||||
<string name="relay_auth_auto_read_follows">\u2026मैं पढ रहा हूँ किसी को जिसका मैं अनुगमन करता हूँ</string>
|
||||
<string name="relay_auth_auto_message_follows">\u2026मैं सन्देश भेज रहा हूँ किसी को जिसका मैं अनुगमन करता हूँ</string>
|
||||
<string name="relay_auth_auto_message_strangers">\u2026मैं किसी अन्य को सन्देश भेज रहा हूँ</string>
|
||||
|
||||
@@ -1111,7 +1111,6 @@
|
||||
keys are new rather than reused - a stale translation of the old standalone titles would
|
||||
read as a non-sequitur under this header. -->
|
||||
<string name="relay_auth_auto_login_when">Jelentkezzen be kérdés nélkül, ha\u2026</string>
|
||||
<string name="relay_auth_auto_my_relays">\u2026ez a saját átjátszóm, vagy egy szoba, amihez csatlakozott</string>
|
||||
<string name="relay_auth_auto_read_follows">\u2026olyan valakit olvasok, akit követek</string>
|
||||
<string name="relay_auth_auto_message_follows">\u2026olyan valakinek írok, akit követek</string>
|
||||
<string name="relay_auth_auto_message_strangers">\u2026bárki másnak írok</string>
|
||||
|
||||
@@ -1174,7 +1174,6 @@ Zaplanowane posty z innych kont nie zostaną opublikowane, dopóki to konto jest
|
||||
keys are new rather than reused - a stale translation of the old standalone titles would
|
||||
read as a non-sequitur under this header. -->
|
||||
<string name="relay_auth_auto_login_when">Zaloguj się bez pytania, kiedy\u2026</string>
|
||||
<string name="relay_auth_auto_my_relays">\u2026to mój transmiter lub pokój, do którego dołączyłem</string>
|
||||
<string name="relay_auth_auto_read_follows">Czytam wpis osoby, którą obserwuję</string>
|
||||
<string name="relay_auth_auto_message_follows">\u2026wysyłam wiadomość do osoby, którą obserwuję</string>
|
||||
<string name="relay_auth_auto_message_strangers">\u2026piszę do wszystkich pozostałych</string>
|
||||
|
||||
@@ -1108,7 +1108,6 @@
|
||||
keys are new rather than reused - a stale translation of the old standalone titles would
|
||||
read as a non-sequitur under this header. -->
|
||||
<string name="relay_auth_auto_login_when">Entrar sem perguntar quando…</string>
|
||||
<string name="relay_auth_auto_my_relays">…for o meu relay, ou uma sala em que entrei</string>
|
||||
<string name="relay_auth_auto_read_follows">…eu estiver lendo alguém que sigo</string>
|
||||
<string name="relay_auth_auto_message_follows">…eu estiver enviando mensagem para alguém que sigo</string>
|
||||
<string name="relay_auth_auto_message_strangers">…eu estiver enviando mensagem para qualquer outra pessoa</string>
|
||||
|
||||
@@ -1108,7 +1108,6 @@
|
||||
keys are new rather than reused - a stale translation of the old standalone titles would
|
||||
read as a non-sequitur under this header. -->
|
||||
<string name="relay_auth_auto_login_when">Logga in utan att fråga när…</string>
|
||||
<string name="relay_auth_auto_my_relays">…det är mitt relä, eller ett rum jag gått med i</string>
|
||||
<string name="relay_auth_auto_read_follows">…jag läser någon jag följer</string>
|
||||
<string name="relay_auth_auto_message_follows">…jag skriver till någon jag följer</string>
|
||||
<string name="relay_auth_auto_message_strangers">…jag skriver till någon annan</string>
|
||||
|
||||
@@ -1152,7 +1152,7 @@
|
||||
keys are new rather than reused - a stale translation of the old standalone titles would
|
||||
read as a non-sequitur under this header. -->
|
||||
<string name="relay_auth_auto_login_when">Log in without asking when\u2026</string>
|
||||
<string name="relay_auth_auto_my_relays">\u2026it\'s my relay, or a room I joined</string>
|
||||
<string name="relay_auth_auto_my_relays_and_venues">\u2026it\'s my relay, or a room I joined or follow</string>
|
||||
<string name="relay_auth_auto_read_follows">\u2026I\'m reading someone I follow</string>
|
||||
<string name="relay_auth_auto_message_follows">\u2026I\'m messaging someone I follow</string>
|
||||
<string name="relay_auth_auto_message_strangers">\u2026I\'m messaging anyone else</string>
|
||||
|
||||
+79
@@ -0,0 +1,79 @@
|
||||
/*
|
||||
* 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.model.preferences
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* The migration precedence in [BuzzAttestationPreferences.restoreFrom]: which of the two on-disk
|
||||
* shapes wins when the held attestation moved from one device-global list to a per-account key.
|
||||
*
|
||||
* The store itself needs a `Context`, so the decision is pulled out as a pure function — this is
|
||||
* the part with the sharp edge, and it is the part the DataStore round-trip cannot express.
|
||||
*/
|
||||
class BuzzAttestationRestoreTest {
|
||||
private val me = "a".repeat(64)
|
||||
private val someoneElse = "b".repeat(64)
|
||||
private val owner = "c".repeat(64)
|
||||
private val sig = "d".repeat(128)
|
||||
|
||||
private fun saved(
|
||||
owner: String = this.owner,
|
||||
conditions: String = "kind=40002",
|
||||
) = """{"owner":"$owner","conditions":"$conditions","sig":"$sig"}"""
|
||||
|
||||
private fun legacyList(vararg agents: String) = agents.joinToString(",", "[", "]") { """{"agent":"$it","owner":"$owner","conditions":"kind=40002","sig":"$sig"}""" }
|
||||
|
||||
@Test
|
||||
fun nothingSavedAnywhereRestoresNothing() {
|
||||
assertNull(BuzzAttestationPreferences.restoreFrom(null, null, me))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun thisAccountsOwnKeyWins() {
|
||||
val restored = BuzzAttestationPreferences.restoreFrom(saved(), legacyList(me), me)
|
||||
assertEquals(owner, restored?.ownerPubKey)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aRemovedAttestationIsNotResurrectedFromTheLegacyList() {
|
||||
// The regression this test exists for. Removing the held credential used to delete the
|
||||
// per-account key, which is indistinguishable from "never migrated" — so the next launch
|
||||
// seeded it straight back out of the legacy list, which nothing ever clears. An explicit
|
||||
// tombstone is the only thing that can say "migrated, and holding nothing".
|
||||
assertNull(BuzzAttestationPreferences.restoreFrom("", legacyList(me), me))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aNeverMigratedAccountTakesItsOwnEntryFromTheLegacyList() {
|
||||
val restored = BuzzAttestationPreferences.restoreFrom(null, legacyList(someoneElse, me), me)
|
||||
assertEquals(owner, restored?.ownerPubKey)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun anotherAgentsLegacyEntryIsNeverPickedUp() {
|
||||
// The legacy list was already agent-keyed, so the migration is exact rather than
|
||||
// best-effort: there is no shared blob to accidentally inherit.
|
||||
assertNull(BuzzAttestationPreferences.restoreFrom(null, legacyList(someoneElse), me))
|
||||
}
|
||||
}
|
||||
+128
@@ -0,0 +1,128 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.service.relayClient.authCommand.model
|
||||
|
||||
import com.vitorpamplona.amethyst.commons.relayauth.AuthPurpose
|
||||
import com.vitorpamplona.amethyst.commons.relayauth.AuthPurposeKind
|
||||
import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthContext
|
||||
import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthCustomToggles
|
||||
import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthDecision
|
||||
import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthPermissionStore
|
||||
import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthPolicy
|
||||
import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthVerdict
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* "…I'm reading someone I follow" has to actually cover the relays it is about.
|
||||
*
|
||||
* The whole point of the toggle is the outbox relay of somebody else — a relay we do not publish to,
|
||||
* do not read our own inbox from, and do not list. That is exactly the shape `isFirstParty` reports
|
||||
* false for, so requiring it emptied the category: with the toggle explicitly on, every one of the
|
||||
* user's follows still produced a login prompt for its outbox relay.
|
||||
*/
|
||||
class RelayAuthReadFollowsTest {
|
||||
private val followsRelay = "wss://outbox.someone-i-follow.example/"
|
||||
private val followed = "a".repeat(64)
|
||||
private val stranger = "b".repeat(64)
|
||||
|
||||
private class NoStore : RelayAuthPermissionStore {
|
||||
override suspend fun loadDecision(relayUrl: String): RelayAuthDecision? = null
|
||||
|
||||
override suspend fun storeDecision(
|
||||
relayUrl: String,
|
||||
decision: RelayAuthDecision,
|
||||
) = Unit
|
||||
|
||||
override suspend fun clearDecision(relayUrl: String) = Unit
|
||||
|
||||
override suspend fun allDecisions(): Map<String, RelayAuthDecision> = emptyMap()
|
||||
}
|
||||
|
||||
private fun ledger(toggles: RelayAuthCustomToggles = RelayAuthCustomToggles()) =
|
||||
RelayAuthPermissionLedger(
|
||||
store = NoStore(),
|
||||
globalPolicy = { RelayAuthPolicy.CUSTOM },
|
||||
customToggles = { toggles },
|
||||
isFollowed = { it == followed },
|
||||
)
|
||||
|
||||
private fun readOutbox(vararg authors: String) = RelayAuthContext(followsRelay, listOf(AuthPurpose(AuthPurposeKind.READ_OUTBOX, authors.toSet())))
|
||||
|
||||
@Test
|
||||
fun readingAFollowAutoAuthenticatesOnTheirOwnOutboxRelay() =
|
||||
runTest {
|
||||
// isFirstParty = false is not an edge case here, it is *the* case: the relay belongs to the
|
||||
// author we are reading. Before the fix this returned ASK, so a user on "decide per relay"
|
||||
// with this toggle on was prompted once per follow.
|
||||
assertEquals(
|
||||
RelayAuthVerdict.ALLOW,
|
||||
ledger().decide(readOutbox(followed), isFirstParty = false),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun readingAFollowStillAsksWhenTheToggleIsOff() =
|
||||
runTest {
|
||||
val off = RelayAuthCustomToggles(readFollows = false)
|
||||
assertEquals(
|
||||
RelayAuthVerdict.ASK,
|
||||
ledger(off).decide(readOutbox(followed), isFirstParty = false),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun readingAStrangerStillAsks() =
|
||||
runTest {
|
||||
// There is deliberately no "read strangers" category — browsing a profile we don't follow
|
||||
// on a relay of theirs is still a question.
|
||||
assertEquals(
|
||||
RelayAuthVerdict.ASK,
|
||||
ledger().decide(readOutbox(stranger), isFirstParty = false),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun oneFollowInABatchedReadIsEnough() =
|
||||
runTest {
|
||||
// Outbox reads are batched per relay, so a single filter routinely names a mix. One
|
||||
// followed author in it is the reason we are on this relay at all.
|
||||
assertEquals(
|
||||
RelayAuthVerdict.ALLOW,
|
||||
ledger().decide(readOutbox(stranger, followed), isFirstParty = false),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun messagingIsNotCoveredByTheReadExemption() =
|
||||
runTest {
|
||||
// Delivering to a followed user's *inbox* keeps the first-party gate: the pending event
|
||||
// would be ours, and when it isn't, the traffic belongs to another logged-in account.
|
||||
val ctx =
|
||||
RelayAuthContext(
|
||||
followsRelay,
|
||||
listOf(AuthPurpose(AuthPurposeKind.SEND_DM, setOf(followed))),
|
||||
)
|
||||
assertEquals(RelayAuthVerdict.ASK, ledger().decide(ctx, isFirstParty = false))
|
||||
assertEquals(RelayAuthVerdict.ALLOW, ledger().decide(ctx, isFirstParty = true))
|
||||
}
|
||||
}
|
||||
+8
-8
@@ -28,10 +28,15 @@ import kotlinx.coroutines.flow.StateFlow
|
||||
* NIP-29 `h`/UUID, globally unique on Buzz). Starred channels float to the top of the community view.
|
||||
*
|
||||
* There is no Nostr event for a personal star — it's the client's own bookkeeping — so, like
|
||||
* [BuzzWorkspaces], this is a process-wide singleton mirrored to a device-global store by the
|
||||
* platform ([com.vitorpamplona.amethyst] `BuzzChannelStarPreferences`) and restored at startup.
|
||||
* [BuzzWorkspaces], it is mirrored to disk by the platform
|
||||
* ([com.vitorpamplona.amethyst] `BuzzChannelStarPreferences`) and restored at startup.
|
||||
*
|
||||
* **One instance per account** (`Account.buzzChannelStars`). A star is by definition personal: it
|
||||
* says which channels *this user* wants pinned to the top of the community view. While it was a
|
||||
* process-wide singleton, one account's favorites reordered and badged every other logged-in
|
||||
* account's channel list — and switching accounts silently rewrote the set they shared.
|
||||
*/
|
||||
object BuzzChannelStars {
|
||||
class BuzzChannelStars {
|
||||
private val starred = MutableStateFlow<Set<String>>(emptySet())
|
||||
|
||||
/** The starred channel ids; the community view collects this to pin + badge them. */
|
||||
@@ -52,9 +57,4 @@ object BuzzChannelStars {
|
||||
fun restore(ids: Set<String>) {
|
||||
starred.value = ids
|
||||
}
|
||||
|
||||
/** Test-only: clears the set so unit tests don't leak state into each other. */
|
||||
fun clearForTesting() {
|
||||
starred.value = emptySet()
|
||||
}
|
||||
}
|
||||
|
||||
+34
-58
@@ -26,75 +26,51 @@ import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
|
||||
/**
|
||||
* Holds the NIP-OA [OwnerAttestation]s this device has *received* — one owner-signed
|
||||
* authorization per agent key that lets that key publish in the owner's Buzz workspace
|
||||
* without being enrolled as a relay member.
|
||||
* The NIP-OA [OwnerAttestation] this account holds — an owner-signed authorization letting its key
|
||||
* publish in the owner's Buzz workspace without being enrolled as a relay member.
|
||||
*
|
||||
* The counterpart of issuance ([OwnerAttestation] is signed by an owner and handed to an
|
||||
* agent operator out-of-band): when the account whose pubkey equals a stored key
|
||||
* authenticates (NIP-42) to a Buzz-dialect relay, the auth coordinator attaches the
|
||||
* matching [OwnerAttestation.toTag] to the AUTH event, and the relay grants virtual
|
||||
* membership while the owner stays a member.
|
||||
* The counterpart of issuance ([OwnerAttestation] is signed by an owner and handed to an agent
|
||||
* operator out-of-band): when this account authenticates (NIP-42) to a Buzz-dialect relay, the auth
|
||||
* coordinator attaches [authTag] to the AUTH event, and the relay grants virtual membership while
|
||||
* the owner stays a member.
|
||||
*
|
||||
* Keyed by the **agent** pubkey (the key the attestation authorizes). Only a
|
||||
* [OwnerAttestation.verify]-passing attestation for that key should be stored, so the
|
||||
* store never carries a credential the relay would reject.
|
||||
*
|
||||
* Like [BuzzRelayDialect] this is a process-wide singleton, and — for now — in-memory
|
||||
* only: a held attestation is re-pasted after a process restart. Persisting it across
|
||||
* launches (per-account, encrypted) is a follow-up.
|
||||
* **One instance per account** (`Account.buzzAttestation`), holding at most one attestation — the
|
||||
* one issued to [agentPubKey]. It was a process-wide `Map<agentPubKey, OwnerAttestation>`, but every
|
||||
* caller only ever read or wrote the entry for the account doing the AUTH, so the map was a
|
||||
* single-entry map with a lookup that could not miss. Owning the agent key here also lets [put]
|
||||
* enforce the verification its callers used to be told to perform, which is the property that
|
||||
* matters: the store never carries a credential the relay would reject.
|
||||
*/
|
||||
object BuzzHeldAttestations {
|
||||
private val heldByAgent = MutableStateFlow<Map<HexKey, OwnerAttestation>>(emptyMap())
|
||||
class BuzzHeldAttestations(
|
||||
private val agentPubKey: HexKey,
|
||||
) {
|
||||
private val held = MutableStateFlow<OwnerAttestation?>(null)
|
||||
|
||||
/** All held attestations, keyed by agent pubkey; UI can collect this. */
|
||||
val flow: StateFlow<Map<HexKey, OwnerAttestation>> = heldByAgent
|
||||
|
||||
/** The attestation held for [agentPubKey], or null. */
|
||||
fun attestationFor(agentPubKey: HexKey): OwnerAttestation? = heldByAgent.value[agentPubKey]
|
||||
/** The attestation held for this account, or null. UI collects this. */
|
||||
val flow: StateFlow<OwnerAttestation?> = held
|
||||
|
||||
/**
|
||||
* The `auth` tag to attach to [agentPubKey]'s NIP-42 AUTH event, or null when no
|
||||
* verified attestation is held for that key.
|
||||
* The `auth` tag to attach to this account's NIP-42 AUTH event, or null when no verified
|
||||
* attestation is held.
|
||||
*/
|
||||
fun authTagFor(agentPubKey: HexKey): Array<String>? = attestationFor(agentPubKey)?.toTag()
|
||||
fun authTag(): Array<String>? = held.value?.toTag()
|
||||
|
||||
/**
|
||||
* Stores [attestation] as authorizing [agentPubKey]. The caller must have already
|
||||
* confirmed `attestation.verify(agentPubKey)`; this is a CAS-loop put so concurrent
|
||||
* writers don't clobber each other.
|
||||
* Stores [attestation] as authorizing this account, if it verifies for [agentPubKey]. Returns
|
||||
* false — storing nothing — when it does not.
|
||||
*
|
||||
* The check lives here rather than in the caller so it cannot be skipped: this is the single
|
||||
* door into the store, used by the paste flow and by the on-disk restore alike, so a tampered
|
||||
* saved credential is dropped by the same gate that rejects a mistyped one.
|
||||
*/
|
||||
fun put(
|
||||
agentPubKey: HexKey,
|
||||
attestation: OwnerAttestation,
|
||||
) {
|
||||
while (true) {
|
||||
val current = heldByAgent.value
|
||||
if (current[agentPubKey] == attestation) return
|
||||
if (heldByAgent.compareAndSet(current, current + (agentPubKey to attestation))) return
|
||||
}
|
||||
fun put(attestation: OwnerAttestation): Boolean {
|
||||
if (!attestation.verify(agentPubKey)) return false
|
||||
held.value = attestation
|
||||
return true
|
||||
}
|
||||
|
||||
/** Removes any attestation held for [agentPubKey]. */
|
||||
fun remove(agentPubKey: HexKey) {
|
||||
while (true) {
|
||||
val current = heldByAgent.value
|
||||
if (agentPubKey !in current) return
|
||||
if (heldByAgent.compareAndSet(current, current - agentPubKey)) return
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Replaces the whole store with [entries] — used to restore from disk at startup. The
|
||||
* caller must have re-verified each attestation against its agent key (the same gate
|
||||
* [put] documents), so a tampered on-disk credential can't be reinstated.
|
||||
*/
|
||||
fun restore(entries: Map<HexKey, OwnerAttestation>) {
|
||||
heldByAgent.value = entries
|
||||
}
|
||||
|
||||
/** Test-only: clears all held attestations so unit tests don't leak state. */
|
||||
fun clearForTesting() {
|
||||
heldByAgent.value = emptyMap()
|
||||
/** Drops the held attestation. */
|
||||
fun clear() {
|
||||
held.value = null
|
||||
}
|
||||
}
|
||||
|
||||
+12
-9
@@ -35,11 +35,19 @@ import kotlinx.coroutines.flow.StateFlow
|
||||
* join event to key off — so this joined set is the client's own bookkeeping of which relays
|
||||
* to sync as workspaces.
|
||||
*
|
||||
* Persisted across launches by the platform (`BuzzWorkspacePreferences` on Android mirrors it
|
||||
* to a device-global store and restores it at startup). Like [BuzzRelayDialect] it is a
|
||||
* process-wide singleton; joining also marks the relay as a Buzz dialect.
|
||||
* **One instance per account** (`Account.buzzWorkspaces`) — deliberately NOT a process-wide
|
||||
* singleton like [BuzzRelayDialect]. Joining is a per-user act: the invite was redeemed by one
|
||||
* key and the relay grants membership to that key alone. While this was device-global it also
|
||||
* fed `AuthCoordinator.isFirstParty`, so one account joining a workspace made *every* logged-in
|
||||
* account first-party there — the bystander-account AUTH leak the per-account gate exists to
|
||||
* prevent. The dialect mark stays global: which protocol a relay speaks is a property of the
|
||||
* relay, not of who is asking.
|
||||
*
|
||||
* Persisted across launches by the platform (`BuzzWorkspacePreferences` on Android mirrors each
|
||||
* account's set to that account's own store and restores it at startup). Joining also marks the
|
||||
* relay as a Buzz dialect.
|
||||
*/
|
||||
object BuzzWorkspaces {
|
||||
class BuzzWorkspaces {
|
||||
private val joined = MutableStateFlow<Set<NormalizedRelayUrl>>(emptySet())
|
||||
|
||||
/** The joined workspace relays; discovery subscriptions and the workspaces hub collect this. */
|
||||
@@ -74,9 +82,4 @@ object BuzzWorkspaces {
|
||||
relays.forEach { BuzzRelayDialect.mark(it) }
|
||||
joined.value = relays
|
||||
}
|
||||
|
||||
/** Test-only: clears the joined set so unit tests don't leak state into each other. */
|
||||
fun clearForTesting() {
|
||||
joined.value = emptySet()
|
||||
}
|
||||
}
|
||||
|
||||
+33
-4
@@ -62,7 +62,8 @@ data class RelayAuthCustomToggles(
|
||||
* there, a subscription there reads its own inbox/outbox, or the relay is in its own relay list.
|
||||
* False means the only reason we are here belongs to somebody else (another logged-in account's
|
||||
* traffic, or a followed author whose outbox happens to live here). Gates the *automatic* grants
|
||||
* only: a non-first-party challenge is never auto-allowed, but it still reaches the user as a
|
||||
* only, and only for the categories it can gate without emptying them (see [RelayAuthResolver]):
|
||||
* a non-first-party challenge is never auto-allowed there, but it still reaches the user as a
|
||||
* prompt rather than a silent denial.
|
||||
*/
|
||||
data class RelayAuthInputs(
|
||||
@@ -92,12 +93,17 @@ data class RelayAuthInputs(
|
||||
* else fall through
|
||||
* 4. Fall-through → [RelayAuthVerdict.ASK] when the purpose is known, otherwise DENY.
|
||||
*
|
||||
* The [RelayAuthPolicy.CUSTOM] grant additionally requires [RelayAuthInputs.isFirstParty]: under
|
||||
* Most [RelayAuthPolicy.CUSTOM] grants additionally require [RelayAuthInputs.isFirstParty]: under
|
||||
* "decide per relay" an account never reveals its identity *without being asked* on a relay it has no
|
||||
* reason of its own to be on, which is what keeps a bystander account off a relay only another account
|
||||
* uses. It deliberately does not suppress the question — a non-first-party challenge we can explain
|
||||
* falls through to ASK, so the user decides rather than getting a silent denial they never see.
|
||||
*
|
||||
* [RelayAuthCustomToggles.readFollows] is the one category exempt from that gate, because the gate is
|
||||
* unsatisfiable there rather than merely strict: reading a followed author means talking to *their*
|
||||
* outbox relay, which is by definition not one we publish to, subscribe to for our own inbox, or list.
|
||||
* See [customAllows].
|
||||
*
|
||||
* [RelayAuthPolicy.ALWAYS] is NOT gated this way: it means what it says, every relay that asks. Users
|
||||
* who want the narrower "only the relays I actually use" behaviour choose CUSTOM.
|
||||
*/
|
||||
@@ -120,14 +126,37 @@ object RelayAuthResolver {
|
||||
// a large follow list, produced a prompt for each of the 250+ third-party outbox relays.
|
||||
RelayAuthPolicy.ALWAYS -> RelayAuthVerdict.ALLOW
|
||||
RelayAuthPolicy.CUSTOM ->
|
||||
if (inputs.isFirstParty && customAllows(inputs)) RelayAuthVerdict.ALLOW else fallThrough(inputs)
|
||||
if (customAllows(inputs)) RelayAuthVerdict.ALLOW else fallThrough(inputs)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether an enabled [RelayAuthCustomToggles] category covers this relay.
|
||||
*
|
||||
* [RelayAuthCustomToggles.readFollows] is checked *before* the [RelayAuthInputs.isFirstParty]
|
||||
* gate because that gate is unsatisfiable for it, not merely strict. "I'm reading someone I
|
||||
* follow" describes their outbox relay: not one we publish to, not one serving our own
|
||||
* inbox/outbox, not one on our list — so `isFirstParty` is false by construction and gating the
|
||||
* category made it unreachable. Every follow's outbox relay prompted even with the toggle on, and
|
||||
* the only challenges it ever granted were ones `myRelaysAndVenues` already covered.
|
||||
*
|
||||
* Exempting it is safe in the way the gate is meant to be: the follow graph consulted is *this*
|
||||
* account's, so no other account's traffic can conjure a match. What it can match is another
|
||||
* logged-in account reading an author we follow too — and the cost of that is an AUTH on a relay
|
||||
* we would be reading that same author from anyway, which is what the toggle asks for.
|
||||
*
|
||||
* Every other category keeps the gate, where it costs them nothing: our own relay list and our
|
||||
* joined rooms' hosts are first-party by definition, and a pending event of ours makes its
|
||||
* destination first-party too. That is precisely what stops a bystander account being
|
||||
* auto-authenticated — and billed — on a paid inbox relay because *another* account's outgoing
|
||||
* DM happens to name someone we follow.
|
||||
*/
|
||||
private fun customAllows(inputs: RelayAuthInputs): Boolean {
|
||||
val t = inputs.toggles
|
||||
if (t.readFollows && inputs.servesFollowedReadCounterparty) return true
|
||||
if (!inputs.isFirstParty) return false
|
||||
|
||||
return (t.myRelaysAndVenues && (inputs.isInMyRelayList || inputs.servesTrustedVenue)) ||
|
||||
(t.readFollows && inputs.servesFollowedReadCounterparty) ||
|
||||
(t.messageFollows && inputs.servesFollowedWriteCounterparty) ||
|
||||
(t.messageStrangers && inputs.servesStrangerWriteCounterparty)
|
||||
}
|
||||
|
||||
+69
@@ -0,0 +1,69 @@
|
||||
/*
|
||||
* 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.model.buzz
|
||||
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class BuzzChannelStarsTest {
|
||||
// A fresh instance per test: stars are per-account state now, not a process-wide singleton.
|
||||
private val stars = BuzzChannelStars()
|
||||
|
||||
private val a = "channel-a"
|
||||
private val b = "channel-b"
|
||||
|
||||
@Test
|
||||
fun toggleStarsAndUnstars() {
|
||||
assertFalse(stars.isStarred(a))
|
||||
assertTrue(stars.toggle(a))
|
||||
assertTrue(stars.isStarred(a))
|
||||
assertEquals(setOf(a), stars.flow.value)
|
||||
|
||||
assertFalse(stars.toggle(a))
|
||||
assertFalse(stars.isStarred(a))
|
||||
assertEquals(emptySet(), stars.flow.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun restoreReplacesTheWholeSet() {
|
||||
stars.toggle(a)
|
||||
stars.restore(setOf(b))
|
||||
assertEquals(setOf(b), stars.flow.value)
|
||||
assertFalse(stars.isStarred(a))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun oneAccountsStarsDoNotPinForAnother() {
|
||||
// Why this is per account: a star reorders and badges the community channel list. While the
|
||||
// set was a process-wide singleton, one account pinning a channel reordered every other
|
||||
// logged-in account's list, and switching accounts rewrote the set they shared.
|
||||
val mine = BuzzChannelStars()
|
||||
val theirs = BuzzChannelStars()
|
||||
|
||||
mine.toggle(a)
|
||||
|
||||
assertTrue(mine.isStarred(a))
|
||||
assertFalse(theirs.isStarred(a))
|
||||
assertEquals(emptySet(), theirs.flow.value)
|
||||
}
|
||||
}
|
||||
+46
-18
@@ -21,44 +21,72 @@
|
||||
package com.vitorpamplona.amethyst.commons.model.buzz
|
||||
|
||||
import com.vitorpamplona.quartz.buzz.oaOwnerAttestation.OwnerAttestation
|
||||
import kotlin.test.AfterTest
|
||||
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertContentEquals
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertNull
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class BuzzHeldAttestationsTest {
|
||||
private val agent = "a".repeat(64)
|
||||
private val owner = "b".repeat(64)
|
||||
private val attestation = OwnerAttestation(ownerPubKey = owner, conditions = "kind=40002", sig = "c".repeat(128))
|
||||
private val agentKey = KeyPair()
|
||||
private val otherKey = KeyPair()
|
||||
private val ownerKey = KeyPair()
|
||||
|
||||
@AfterTest
|
||||
fun tearDown() = BuzzHeldAttestations.clearForTesting()
|
||||
private val agent = agentKey.pubKey.toHexKey()
|
||||
private val other = otherKey.pubKey.toHexKey()
|
||||
|
||||
private val attestation = OwnerAttestation.sign(agent, CONDITIONS, ownerKey.privKey!!)
|
||||
|
||||
// One store per account, holding the attestation issued to that account's key.
|
||||
private val held = BuzzHeldAttestations(agent)
|
||||
|
||||
@Test
|
||||
fun emptyStoreYieldsNoTag() {
|
||||
assertNull(BuzzHeldAttestations.attestationFor(agent))
|
||||
assertNull(BuzzHeldAttestations.authTagFor(agent))
|
||||
assertNull(held.flow.value)
|
||||
assertNull(held.authTag())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun heldAttestationSurfacesAsItsAuthTag() {
|
||||
BuzzHeldAttestations.put(agent, attestation)
|
||||
assertEquals(attestation, BuzzHeldAttestations.attestationFor(agent))
|
||||
assertTrue(held.put(attestation))
|
||||
assertEquals(attestation, held.flow.value)
|
||||
// The tag attached to the agent's AUTH is exactly the attestation's ["auth", …] tag.
|
||||
assertContentEquals(attestation.toTag(), BuzzHeldAttestations.authTagFor(agent))
|
||||
assertContentEquals(attestation.toTag(), held.authTag())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun removeClearsTheHeldAttestation() {
|
||||
BuzzHeldAttestations.put(agent, attestation)
|
||||
BuzzHeldAttestations.remove(agent)
|
||||
assertNull(BuzzHeldAttestations.authTagFor(agent))
|
||||
fun clearDropsTheHeldAttestation() {
|
||||
held.put(attestation)
|
||||
held.clear()
|
||||
assertNull(held.authTag())
|
||||
assertNull(held.flow.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun oneAgentsAttestationDoesNotLeakToAnother() {
|
||||
BuzzHeldAttestations.put(agent, attestation)
|
||||
assertNull(BuzzHeldAttestations.authTagFor("d".repeat(64)))
|
||||
fun anAttestationIssuedToAnotherKeyIsRejected() {
|
||||
// The check that used to be the caller's job: this credential is real and verifies — for
|
||||
// somebody else's key. Storing it would attach an `auth` tag the relay rejects, and the
|
||||
// store's whole contract is that it never holds one.
|
||||
val theirs = BuzzHeldAttestations(other)
|
||||
|
||||
assertFalse(theirs.put(attestation))
|
||||
assertNull(theirs.authTag())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aTamperedAttestationIsRejectedAndLeavesTheHeldOneIntact() {
|
||||
held.put(attestation)
|
||||
|
||||
val forged = attestation.copy(conditions = "kind=1")
|
||||
|
||||
assertFalse(held.put(forged))
|
||||
assertEquals(attestation, held.flow.value)
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val CONDITIONS = "kind=40002"
|
||||
}
|
||||
}
|
||||
|
||||
+44
-14
@@ -29,44 +29,74 @@ import kotlin.test.assertFalse
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class BuzzWorkspacesTest {
|
||||
// A fresh instance per test: the joined set is per-account state now, not a process-wide
|
||||
// singleton, so there is nothing to reset between tests. The dialect mark stays global.
|
||||
private val workspaces = BuzzWorkspaces()
|
||||
|
||||
private val a = RelayUrlNormalizer.normalize("wss://a.buzz.example")
|
||||
private val b = RelayUrlNormalizer.normalize("wss://b.buzz.example")
|
||||
|
||||
@BeforeTest fun setup() {
|
||||
BuzzWorkspaces.clearForTesting()
|
||||
BuzzRelayDialect.clearForTesting()
|
||||
}
|
||||
|
||||
@AfterTest fun teardown() {
|
||||
BuzzWorkspaces.clearForTesting()
|
||||
BuzzRelayDialect.clearForTesting()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun joiningRecordsAndMarksDialect() {
|
||||
assertTrue(BuzzWorkspaces.join(a))
|
||||
assertTrue(BuzzWorkspaces.isJoined(a))
|
||||
assertEquals(setOf(a), BuzzWorkspaces.flow.value)
|
||||
assertTrue(workspaces.join(a))
|
||||
assertTrue(workspaces.isJoined(a))
|
||||
assertEquals(setOf(a), workspaces.flow.value)
|
||||
// Joining also marks the relay a Buzz dialect so its events render as workspace channels.
|
||||
assertTrue(BuzzRelayDialect.isBuzz(a))
|
||||
// Re-joining is a no-op (returns false).
|
||||
assertFalse(BuzzWorkspaces.join(a))
|
||||
assertFalse(workspaces.join(a))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun leaveRemoves() {
|
||||
BuzzWorkspaces.join(a)
|
||||
BuzzWorkspaces.join(b)
|
||||
BuzzWorkspaces.leave(a)
|
||||
assertEquals(setOf(b), BuzzWorkspaces.flow.value)
|
||||
assertFalse(BuzzWorkspaces.isJoined(a))
|
||||
workspaces.join(a)
|
||||
workspaces.join(b)
|
||||
workspaces.leave(a)
|
||||
assertEquals(setOf(b), workspaces.flow.value)
|
||||
assertFalse(workspaces.isJoined(a))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun restoreReplacesAndMarksAll() {
|
||||
BuzzWorkspaces.join(a)
|
||||
BuzzWorkspaces.restore(setOf(b))
|
||||
assertEquals(setOf(b), BuzzWorkspaces.flow.value)
|
||||
workspaces.join(a)
|
||||
workspaces.restore(setOf(b))
|
||||
assertEquals(setOf(b), workspaces.flow.value)
|
||||
assertTrue(BuzzRelayDialect.isBuzz(b))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun oneAccountsJoinDoesNotJoinForAnother() {
|
||||
// The bystander-AUTH leak this became per-account for: while the joined set was a
|
||||
// process-wide singleton it fed AuthCoordinator.isFirstParty, so an account that never
|
||||
// redeemed the invite was auto-authenticated (and identified) on someone else's workspace.
|
||||
val mine = BuzzWorkspaces()
|
||||
val theirs = BuzzWorkspaces()
|
||||
|
||||
mine.join(a)
|
||||
|
||||
assertTrue(mine.isJoined(a))
|
||||
assertFalse(theirs.isJoined(a))
|
||||
assertEquals(emptySet(), theirs.flow.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun leavingOnOneAccountLeavesTheOtherJoined() {
|
||||
val mine = BuzzWorkspaces()
|
||||
val theirs = BuzzWorkspaces()
|
||||
mine.join(a)
|
||||
theirs.join(a)
|
||||
|
||||
mine.leave(a)
|
||||
|
||||
assertFalse(mine.isJoined(a))
|
||||
assertTrue(theirs.isJoined(a))
|
||||
}
|
||||
}
|
||||
|
||||
+41
-2
@@ -98,6 +98,31 @@ class RelayAuthResolverTest {
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun readFollowsGrantsOnTheFollowsOwnOutboxRelay() {
|
||||
// The situation the toggle is *named for*: someone we follow publishes to a relay of theirs
|
||||
// that we do not use. `isFirstParty` is false by construction there — the relay is theirs, we
|
||||
// have no traffic of our own on it — so gating this category on it made "…I'm reading someone
|
||||
// I follow" unreachable: every follow's outbox relay prompted, on an account with the toggle
|
||||
// explicitly on. The only time it ever granted was when the relay was also on our own list,
|
||||
// where `myRelaysAndVenues` already covered it.
|
||||
assertEquals(
|
||||
RelayAuthVerdict.ALLOW,
|
||||
resolve(inputs(servesFollowedReadCounterparty = true, isFirstParty = false)),
|
||||
)
|
||||
// Still off when the toggle is off.
|
||||
assertEquals(
|
||||
RelayAuthVerdict.ASK,
|
||||
resolve(
|
||||
inputs(
|
||||
servesFollowedReadCounterparty = true,
|
||||
isFirstParty = false,
|
||||
toggles = RelayAuthCustomToggles(readFollows = false),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun customMessageFollowsToggleGatesMessagingFollows() {
|
||||
assertEquals(RelayAuthVerdict.ALLOW, resolve(inputs(servesFollowedWriteCounterparty = true)))
|
||||
@@ -140,9 +165,22 @@ class RelayAuthResolverTest {
|
||||
val allOn = RelayAuthCustomToggles(myRelaysAndVenues = true, readFollows = true, messageFollows = true, messageStrangers = true)
|
||||
assertEquals(RelayAuthVerdict.ASK, resolve(inputs(toggles = allOn, isInMyRelayList = true, isFirstParty = false)))
|
||||
assertEquals(RelayAuthVerdict.ASK, resolve(inputs(toggles = allOn, servesTrustedVenue = true, isFirstParty = false)))
|
||||
assertEquals(RelayAuthVerdict.ASK, resolve(inputs(toggles = allOn, servesFollowedReadCounterparty = true, isFirstParty = false)))
|
||||
assertEquals(RelayAuthVerdict.ASK, resolve(inputs(toggles = allOn, servesFollowedWriteCounterparty = true, isFirstParty = false)))
|
||||
assertEquals(RelayAuthVerdict.ASK, resolve(inputs(toggles = allOn, servesStrangerWriteCounterparty = true, isFirstParty = false)))
|
||||
// readFollows is deliberately absent: see readFollowsGrantsOnTheFollowsOwnOutboxRelay. Its
|
||||
// relay is the *follow's*, never ours, so the gate could only ever empty the category.
|
||||
}
|
||||
|
||||
@Test
|
||||
fun readFollowsExemptionDoesNotLeakIntoTheOtherCategories() {
|
||||
// Only the read category is exempt. With readFollows on but nothing being read from a follow,
|
||||
// a non-first-party relay still asks for every other reason it might want us.
|
||||
val allOn = RelayAuthCustomToggles(myRelaysAndVenues = true, readFollows = true, messageFollows = true, messageStrangers = true)
|
||||
assertEquals(RelayAuthVerdict.ASK, resolve(inputs(toggles = allOn, isInMyRelayList = true, isFirstParty = false)))
|
||||
assertEquals(RelayAuthVerdict.ASK, resolve(inputs(toggles = allOn, servesFollowedWriteCounterparty = true, isFirstParty = false)))
|
||||
// The bystander case the gate exists for: another account's outgoing DM names someone we
|
||||
// follow. Ours is not the traffic, so we do not sign for it without being asked.
|
||||
assertEquals(RelayAuthVerdict.ASK, resolve(inputs(toggles = allOn, servesStrangerWriteCounterparty = true, isFirstParty = false)))
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -158,7 +196,8 @@ class RelayAuthResolverTest {
|
||||
@Test
|
||||
fun customPolicyStillRequiresFirstParty() {
|
||||
// The first-party gate belongs to CUSTOM: a toggle that matches is not enough if the only reason
|
||||
// we are on this relay belongs to somebody else.
|
||||
// we are on this relay belongs to somebody else. (Except readFollows, whose relay always
|
||||
// belongs to the follow — see readFollowsGrantsOnTheFollowsOwnOutboxRelay.)
|
||||
val allOn = RelayAuthCustomToggles(myRelaysAndVenues = true, readFollows = true, messageFollows = true, messageStrangers = true)
|
||||
assertEquals(RelayAuthVerdict.ALLOW, resolve(inputs(toggles = allOn, isInMyRelayList = true, isFirstParty = true)))
|
||||
assertEquals(RelayAuthVerdict.ASK, resolve(inputs(toggles = allOn, isInMyRelayList = true, isFirstParty = false)))
|
||||
|
||||
+30
-6
@@ -27,8 +27,12 @@ import kotlin.io.encoding.ExperimentalEncodingApi
|
||||
|
||||
/**
|
||||
* A parsed Buzz workspace invite link: `https://<host>/invite/<code>`, where `<code>` is a
|
||||
* relay-signed token `<payloadB64url>.<sigB64url>` (base64url, JWT-style but not a JWT). The
|
||||
* payload names the community, the granted role, an expiry and a nonce.
|
||||
* relay-signed token in one of two shapes:
|
||||
*
|
||||
* - `<payloadB64url>.<sigB64url>` (base64url, JWT-style but not a JWT) — the payload names the
|
||||
* community, the granted role, an expiry and a nonce.
|
||||
* - `v2.<opaqueB64url>` — a bare server-side handle. Nothing about the invite is readable here;
|
||||
* the relay resolves it on claim.
|
||||
*
|
||||
* A Buzz invite is **not** a NIP-29 invite code (kind 9009) — it is redeemed over HTTP against
|
||||
* the relay's tenant host: `POST /api/invites/claim`, NIP-98-signed by the joining key, after
|
||||
@@ -43,11 +47,15 @@ data class BuzzInvite(
|
||||
val host: String,
|
||||
/** The full opaque token (`payload.sig`) to hand back to the relay's claim endpoint. */
|
||||
val code: String,
|
||||
/** The community (workspace/tenant) UUID the invite admits into — the payload's `c`. */
|
||||
/**
|
||||
* The community (workspace/tenant) UUID the invite admits into — the payload's `c`. Empty for
|
||||
* a `v2.` token, whose code is opaque: the relay resolves the community on claim and returns
|
||||
* it, so nothing client-side needs it (the join hands off to the tenant host, not the id).
|
||||
*/
|
||||
val communityId: String,
|
||||
/** The role granted on claim (e.g. `member`) — the payload's `r`. */
|
||||
/** The role granted on claim (e.g. `member`) — the payload's `r`, or [DEFAULT_ROLE] for `v2.`. */
|
||||
val role: String,
|
||||
/** Unix-seconds expiry, or null when the payload omits it — the payload's `e`. */
|
||||
/** Unix-seconds expiry, or null when the payload omits it (always for `v2.`) — the payload's `e`. */
|
||||
val expiresAt: Long?,
|
||||
) {
|
||||
/** The tenant's relay websocket URL. */
|
||||
@@ -63,6 +71,12 @@ data class BuzzInvite(
|
||||
object BuzzInviteLink {
|
||||
private const val MARKER = "/invite/"
|
||||
|
||||
/** The payload segment of an opaque, server-resolved token. */
|
||||
private const val V2_PREFIX = "v2"
|
||||
|
||||
/** What the relay grants when the token doesn't say — and it never says for `v2.`. */
|
||||
private const val DEFAULT_ROLE = "member"
|
||||
|
||||
private val JSON = Json { ignoreUnknownKeys = true }
|
||||
|
||||
@Serializable
|
||||
@@ -100,6 +114,16 @@ object BuzzInviteLink {
|
||||
val payloadB64 = code.substringBefore('.')
|
||||
if (payloadB64 == code || payloadB64.isEmpty()) return null
|
||||
|
||||
// `v2.<opaque>` carries no client-readable payload — the community, role and expiry live
|
||||
// only on the relay, which resolves the code on claim and returns them. There is nothing
|
||||
// to decode and nothing to lose by admitting it: the join flow needs the host (for the
|
||||
// relay url and the REST base) and the code, both of which the url itself carries, and the
|
||||
// claim response supplies the rest. Matched on the literal prefix rather than by relaxing
|
||||
// the decode below, so `…/invite/anything.else` still fails to parse.
|
||||
if (payloadB64 == V2_PREFIX) {
|
||||
return BuzzInvite(host = host, code = code, communityId = "", role = DEFAULT_ROLE, expiresAt = null)
|
||||
}
|
||||
|
||||
val payload =
|
||||
try {
|
||||
val bytes = Base64.UrlSafe.decode(padBase64(payloadB64))
|
||||
@@ -113,7 +137,7 @@ object BuzzInviteLink {
|
||||
host = host,
|
||||
code = code,
|
||||
communityId = community,
|
||||
role = payload.r?.takeIf { it.isNotBlank() } ?: "member",
|
||||
role = payload.r?.takeIf { it.isNotBlank() } ?: DEFAULT_ROLE,
|
||||
expiresAt = payload.e,
|
||||
)
|
||||
}
|
||||
|
||||
+37
@@ -59,6 +59,37 @@ class BuzzInviteLinkTest {
|
||||
assertEquals("c03abaa9-65e4-43b1-b9b3-f502a2812d0b", BuzzInviteLink.parse("$realUrl?ref=1")!!.communityId)
|
||||
}
|
||||
|
||||
// A real v2 token minted by amethyst.communities.buzz.xyz: `v2.` plus an opaque handle. Nothing
|
||||
// about the invite is encoded in it — the relay resolves the code when the claim arrives.
|
||||
private val v2Token = "v2.WWsMv33mYH8o04ZGdcoZKmIImGOEMW7auc5cZ0UdH24"
|
||||
private val v2Url = "https://amethyst.communities.buzz.xyz/invite/$v2Token"
|
||||
|
||||
@Test
|
||||
fun parsesAnOpaqueV2Invite() {
|
||||
val invite = BuzzInviteLink.parse(v2Url)!!
|
||||
assertEquals("amethyst.communities.buzz.xyz", invite.host)
|
||||
assertEquals(v2Token, invite.code)
|
||||
// Unknowable client-side: the claim response carries the community and the granted role.
|
||||
assertEquals("", invite.communityId)
|
||||
assertEquals("member", invite.role)
|
||||
assertNull(invite.expiresAt)
|
||||
// What the join actually needs, both derived from the host.
|
||||
assertEquals("wss://amethyst.communities.buzz.xyz", invite.relayUrl())
|
||||
assertEquals("https://amethyst.communities.buzz.xyz", invite.httpBase())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aV2InviteNeverExpiresClientSide() {
|
||||
// No expiry to check, so the courtesy check must not block the claim — the relay decides.
|
||||
assertTrue(!BuzzInviteLink.parse(v2Url)!!.isExpired(Long.MAX_VALUE))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun toleratesTrailingFragmentAndQueryOnV2() {
|
||||
assertEquals(v2Token, BuzzInviteLink.parse("$v2Url#x")!!.code)
|
||||
assertEquals(v2Token, BuzzInviteLink.parse("$v2Url?ref=1")!!.code)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun rejectsNonInviteAndConcordShapes() {
|
||||
assertNull(BuzzInviteLink.parse("https://amethyst.communities.buzz.xyz/"))
|
||||
@@ -67,5 +98,11 @@ class BuzzInviteLinkTest {
|
||||
assertNull(BuzzInviteLink.parse("https://amethyst.social/invite/naddr1abcdef#deadbeef"))
|
||||
// Dotless token → not a Buzz invite.
|
||||
assertNull(BuzzInviteLink.parse("https://host.example/invite/justsometext"))
|
||||
// The v2 exemption is the literal prefix, not "give up on decoding": an undecodable
|
||||
// payload with any other prefix is still not an invite.
|
||||
assertNull(BuzzInviteLink.parse("https://host.example/invite/v3.WWsMv33mYH8o04ZGdcoZKmI"))
|
||||
assertNull(BuzzInviteLink.parse("https://host.example/invite/notbase64json.sig"))
|
||||
// `v2` without the dot is a dotless token like any other.
|
||||
assertNull(BuzzInviteLink.parse("https://host.example/invite/v2"))
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user