diff --git a/.claude/skills/relay-client/SKILL.md b/.claude/skills/relay-client/SKILL.md index c1019cde8f..1b72fb6977 100644 --- a/.claude/skills/relay-client/SKILL.md +++ b/.claude/skills/relay-client/SKILL.md @@ -94,6 +94,32 @@ class MetadataFilterAssembler( Assemblers stay pure — no state, no I/O. They're the composition seam: `FeedMetadataCoordinator` takes a list of visible notes and assembles a single metadata filter covering every referenced pubkey. +## Per-visible loading — the canonical entry points (`observeUser*` / `observeNote*`) + +Prefer these over hand-rolled "load metadata for this list" calls. They are the shared, +KMP way to load data **only for what's on screen** — a composable subscribes while it is in +composition and unsubscribes ~30s after it leaves (or the app backgrounds). Both live in +`commons/relayClient/`: + +- **Per user** (`relayClient/user/`): `observeUserInfo/Picture/Banner/AboutMe/Name(user)` + each open a composition-scoped `UserFinderFilterAssemblerSubscription(user)` **and** return + reactive `State`. Metadata (kind 0 + relay lists) loads for on-screen users only, coalesced + into one batched REQ per relay for the whole visible set. +- **Per note** (`relayClient/event/`): `EventFinderFilterAssemblerSubscription(note)` loads a + note's interactions (reactions / zaps / reposts / replies) while it is composed. Android's + `observeNote*` display observers layer on top of the same subscription. + +Both read front-end-provided CompositionLocals — `LocalUserFinder` / `LocalUserFinderAccount` +(reused by the event finder) / `LocalEventFinder` — provided once near the composition root +(Android `AppModules`, Desktop `Main.kt` via its subscriptions coordinator). The account seam +is the narrow `UserFinderAccount` (snapshot relay-hint getters), NOT the fat `IAccount`. +`error()` defaults mean these must never be reached from a composition without a relay client +(e.g. the Android `:napplet` sandbox). + +The load-once, viewport-batch path (`FeedMetadataCoordinator.loadMetadataForNotes` / +`loadMetadataBatched`) is superseded for foreground loading; `MetadataPreloader` remains only +as an optional off-screen background warmer. + ## Preloaders `MetadataPreloader` is the "I need metadata for 200 pubkeys, but don't melt my CPU or the relay" path. It uses `MetadataRateLimiter` (token bucket) to throttle bulk fetches and group them into relay-friendly chunks. diff --git a/.github/workflows/smoke-test-desktop.yml b/.github/workflows/smoke-test-desktop.yml index 6d8159ab0e..57b992ae73 100644 --- a/.github/workflows/smoke-test-desktop.yml +++ b/.github/workflows/smoke-test-desktop.yml @@ -92,13 +92,29 @@ jobs: chmod +x scripts/relax-deb-libicu.sh scripts/relax-deb-libicu.sh desktopApp/build/compose/binaries/main-release/deb/*.deb + # Mirrors the same step in create-release.yml so this job exercises the + # exact .deb release ships. libskiko-linux-arm64.so has libEGL.so.1 in + # DT_NEEDED and jpackage does not scan lib/app/ for Depends, so without + # this the arm64 app dies at startup with + # UnsatisfiedLinkError: libEGL.so.1: cannot open shared object file + # See scripts/add-deb-libegl-dep.sh for the full rationale. + - name: Add libegl1 dep to arm64 .deb + run: | + set -euo pipefail + chmod +x scripts/add-deb-libegl-dep.sh + scripts/add-deb-libegl-dep.sh desktopApp/build/compose/binaries/main-release/deb/*.deb + - name: Install .deb run: | + # Installed via apt (not `dpkg -i`) so the .deb's declared Depends are + # actually resolved — that is what pulls in libegl1 on the arm64 + # runner, which does not ship it preinstalled. + # # jpackage's post-install script runs xdg-desktop-menu which fails # on CI runners ("No writable system menu directory"). The files are # extracted successfully; only the menu registration fails. Allow the - # dpkg error, then verify the binary was actually installed. - sudo dpkg -i desktopApp/build/compose/binaries/main-release/deb/*.deb || true + # install error, then verify the binary was actually installed. + sudo apt-get install -y ./desktopApp/build/compose/binaries/main-release/deb/*.deb || true echo "Installed files:" dpkg -L amethyst | head -30 # Fail if the binary wasn't actually extracted diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/favorites/FavoriteAppLauncher.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/favorites/FavoriteAppLauncher.kt index bfdc4466ed..f46e2e7f24 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/favorites/FavoriteAppLauncher.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/favorites/FavoriteAppLauncher.kt @@ -165,27 +165,17 @@ object FavoriteAppLauncher { return when (event) { is RootNappletEvent -> NappletLauncher.buildLaunchParams( - context, - event.paths(), - event.servers(), - event.pubKey, - "", - event.declaredAggregateHash() ?: event.computeAggregateHash(), - event.title() ?: "Napplet", - event.requires(), - HostProfile.NAPPLET, + context = context, + manifest = event, + authorPubKey = event.pubKey, + identifier = "", ) is NamedNappletEvent -> NappletLauncher.buildLaunchParams( - context, - event.paths(), - event.servers(), - event.pubKey, - event.identifier(), - event.declaredAggregateHash() ?: event.computeAggregateHash(), - event.title() ?: event.identifier(), - event.requires(), - HostProfile.NAPPLET, + context = context, + manifest = event, + authorPubKey = event.pubKey, + identifier = event.identifier(), ) is RootSiteEvent -> NappletLauncher.buildLaunchParams( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt index 57d6f64e5e..4cfab50126 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -30,6 +30,8 @@ import com.vitorpamplona.amethyst.commons.connectedApps.nip46.Nip46ClientStore import com.vitorpamplona.amethyst.commons.connectedApps.signers.InMemoryNostrSignerPermissionStore import com.vitorpamplona.amethyst.commons.connectedApps.signers.NostrSignerPermissionLedger import com.vitorpamplona.amethyst.commons.connectedApps.signers.NostrSignerPermissionStore +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.BuzzRelayDialect @@ -59,6 +61,7 @@ import com.vitorpamplona.amethyst.commons.model.nip85TrustedAssertions.ContactCa import com.vitorpamplona.amethyst.commons.model.nip85TrustedAssertions.ContactCardsState import com.vitorpamplona.amethyst.commons.model.nip85TrustedAssertions.TrustProviderListDecryptionCache import com.vitorpamplona.amethyst.commons.model.privateChats.hasEncryptedContent +import com.vitorpamplona.amethyst.commons.relayClient.user.UserFinderAccount import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthCustomToggles import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthPermissionStore import com.vitorpamplona.amethyst.commons.richtext.RichTextParser @@ -286,6 +289,7 @@ import com.vitorpamplona.quartz.nip72ModCommunities.rules.CommunityRulesEvent import com.vitorpamplona.quartz.nip72ModCommunities.rules.tags.KindRuleTag import com.vitorpamplona.quartz.nip72ModCommunities.rules.tags.PubkeyRuleTag import com.vitorpamplona.quartz.nip72ModCommunities.rules.tags.WotTag +import com.vitorpamplona.quartz.nip85TrustedAssertions.list.tags.ServiceProviderTag import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent import com.vitorpamplona.quartz.nip88Polls.response.PollResponseEvent import com.vitorpamplona.quartz.nip89AppHandlers.clientTag.NostrSignerWithClientTag @@ -354,7 +358,8 @@ class Account( relayAuthPermissionStore: RelayAuthPermissionStore = InMemoryRelayAuthPermissionStore(), signerPermissionStore: NostrSignerPermissionStore = InMemoryNostrSignerPermissionStore(), nip46ClientStore: Nip46ClientStore = InMemoryNip46ClientStore(), -) : IAccount { +) : IAccount, + UserFinderAccount { private var userProfileCache: User? = null override fun userProfile(): User = userProfileCache ?: cache.getOrCreateUser(signer.pubKey).also { userProfileCache = it } @@ -366,6 +371,34 @@ class Account( override val hiddenUsersHashCodes: Set get() = hiddenUsers.flow.value.hiddenUsersHashCodes override val spammersHashCodes: Set get() = hiddenUsers.flow.value.spammersHashCodes + // UserFinderAccount — narrow, read-only relay-hint view used by the shared + // per-user metadata + per-note event finders (moved to commons). Snapshot + // getters read `.value` fresh on every filter rebuild. userFinderPubkeyHex + // doubles as the attribution pubkey for ExplainedFilter.accountPubKeys. + override val userFinderPubkeyHex: HexKey get() = userProfile().pubkeyHex + + override fun indexRelays(): Set = indexerRelayList.flow.value.ifEmpty { DefaultIndexerRelayList } + + override fun outboxHomeRelays(): Set = nip65RelayList.allFlowNoDefaults.value + privateStorageRelayList.flow.value + localRelayList.flow.value + + // searchRelayList.flow already applies the DefaultSearchRelayList fallback internally + // (SearchRelayListState.normalizeSearchRelayListWithBackup), so no ifEmpty needed here. + override fun searchRelays(): Set = (trustedRelayList.flow.value + searchRelayList.flow.value).toSet() + + override fun searchOnlyRelays(): Set = searchRelayList.flow.value + + override fun followPlusAllMineWithSearchRelays(): Set = followPlusAllMineWithSearch.flow.value + + override fun commonRelays(): Set = followSharedOutboxesOrProxy.flow.value.ifEmpty { Constants.eventFinderRelays } + + override fun cardHomeRelays(): Set = homeRelays.flow.value + + override fun trustProvider(): ServiceProviderTag? = trustProviderList.liveUserRankProvider.value + + override fun followerCountProvider(): ServiceProviderTag? = trustProviderList.liveUserFollowerCount.value + + override fun declaredFollowsByOutboxRelay(): Map> = declaredFollowsPerOutboxRelay.value + val userMetadata = UserMetadataState(signer, cache, scope, settings) // Per-account NIP-42 ALLOW/DENY overrides, warm-cached in memory so a relay AUTH challenge is diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountConcordActions.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountConcordActions.kt index fba41f5e01..1bf9339ae4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountConcordActions.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountConcordActions.kt @@ -22,6 +22,7 @@ package com.vitorpamplona.amethyst.model import com.vitorpamplona.amethyst.commons.actions.ConcordActions import com.vitorpamplona.amethyst.commons.actions.ConcordModeration +import com.vitorpamplona.amethyst.commons.actions.ConcordReceive import com.vitorpamplona.amethyst.commons.actions.ConcordSubscriptionPlanner import com.vitorpamplona.amethyst.commons.model.concord.ConcordChannel import com.vitorpamplona.amethyst.commons.model.concord.ConcordCommunitySession @@ -33,18 +34,19 @@ import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEven import com.vitorpamplona.quartz.concord.cord02Community.HeldRoot import com.vitorpamplona.quartz.concord.cord02Community.ImagePointer import com.vitorpamplona.quartz.concord.cord03Channels.ChannelChat +import com.vitorpamplona.quartz.concord.cord04Roles.AuthorityResolver import com.vitorpamplona.quartz.concord.cord04Roles.ChannelEntity -import com.vitorpamplona.quartz.concord.cord04Roles.ConcordJson import com.vitorpamplona.quartz.concord.cord04Roles.ConcordPermissions -import com.vitorpamplona.quartz.concord.cord04Roles.ControlEntityKind -import com.vitorpamplona.quartz.concord.cord04Roles.ControlRootWrap -import com.vitorpamplona.quartz.concord.cord04Roles.GrantEntity import com.vitorpamplona.quartz.concord.cord04Roles.MetadataEntity import com.vitorpamplona.quartz.concord.cord04Roles.RoleEntity import com.vitorpamplona.quartz.concord.cord05Invites.CommunityInvite +import com.vitorpamplona.quartz.concord.cord05Invites.ConcordInviteList +import com.vitorpamplona.quartz.concord.cord05Invites.ConcordInviteListDocument +import com.vitorpamplona.quartz.concord.cord05Invites.ConcordInviteListEntry +import com.vitorpamplona.quartz.concord.cord05Invites.ConcordInviteListEvent +import com.vitorpamplona.quartz.concord.cord05Invites.ConcordInviteListTombstone import com.vitorpamplona.quartz.concord.cord05Invites.InviteBundleStatus import com.vitorpamplona.quartz.concord.cord05Invites.InviteRelayDictionary -import com.vitorpamplona.quartz.concord.crypto.ConcordKeyDerivation import com.vitorpamplona.quartz.concord.crypto.ControlPlaneKeys import com.vitorpamplona.quartz.concord.crypto.GroupKey import com.vitorpamplona.quartz.concord.envelope.ConcordStreamEnvelope @@ -52,8 +54,11 @@ import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.anyRelayServed import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.fetchAll import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.fetchAllPagesFromPool +import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.fetchAllWithHooks +import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.publishAndConfirm import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer @@ -63,6 +68,9 @@ import com.vitorpamplona.quartz.nipC7Chats.ChatEvent import com.vitorpamplona.quartz.utils.Log import com.vitorpamplona.quartz.utils.RandomInstance import com.vitorpamplona.quartz.utils.TimeUtils +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.coroutineScope import java.util.concurrent.ConcurrentHashMap /** Name of the default Concord community Admin role minted by "Make admin". */ @@ -76,6 +84,15 @@ private const val CONCORD_ADMIN_ROLE = "Admin" */ private const val RECOVERY_CHECK_INTERVAL_MS = 15 * 60 * 1000L +/** + * How many recipients one Refounding will re-key. See `AccountConcordActions.boundRecipients`. + * + * 120 blobs ride in each kind-3303 chunk, so this is ~42 published events and ~5k NIP-44 + * encryptions at the ceiling — heavy but survivable on a phone, and far above any real community. + * Raising it raises the cost of the attack it exists to bound, not the safety. + */ +private const val MAX_REFOUNDING_RECIPIENTS = 5_000 + /** * Concord (encrypted communities) orchestration for an [Account]: join/create/ * invite flows, channel messages/reactions/edits/typing, roles and moderation, @@ -158,6 +175,138 @@ class AccountConcordActions( return community.communityIdHex } + // ---- CORD-05 Invite List (kind 13303) ------------------------------------- + + /** + * This account's Invite List (kind 13303): the creator's private, self-encrypted record of every + * link they minted (`token` + `signer_sk` per entry). + * + * Returns **null** when the list could not be read — no relay answered, or the signer refused + * the decrypt — and an empty document only when the account genuinely has no list yet. Callers + * must not conflate the two: republishing an "empty" list over this replaceable coordinate + * destroys every `signer_sk` it failed to read, and those secrets cannot be regenerated. + * + * Read on the account's OUTBOX relays, never a community's: the coordinate is + * (13303, me, "") — one list for the whole account — so scoping it per community would fork it + * into divergent versions that the newest-wins rule then silently collapses. + * + * Fetched rather than read from [LocalCache] because nothing subscribes to 13303: it is + * bookkeeping the user never sees, needed only at mint and at rotation. + */ + private suspend fun readConcordInviteList(): ConcordInviteListDocument? { + val relays = account.outboxRelays.flow.value + if (relays.isEmpty()) return null + val filter = Filter(kinds = listOf(ConcordInviteListEvent.KIND), authors = listOf(account.signer.pubKey)) + // Terminal reasons, not just events: `fetchAll` returns an empty list both when a relay + // served us and had nothing AND when nothing answered at all (cannot-connect, CLOSED, idle + // timeout). Treating the second as "no list yet" is precisely how a read-merge-write wipes + // the signer_sk of every link it failed to read, so the two must be told apart. + val reasons = mutableMapOf() + val events = + account.client.fetchAllWithHooks( + filters = relays.associateWith { listOf(filter) }, + doneOut = reasons, + ) { _, _ -> true } + + val newest = + events + .mapNotNull { it.second as? ConcordInviteListEvent } + // Filter by kind BEFORE picking the newest: taking the newest of anything and then + // casting means one stray event at this coordinate reads as "unreadable" forever. + .maxByOrNull { it.createdAt } + ?: return if (reasons.anyRelayServed()) { + ConcordInviteListDocument.EMPTY // a relay answered and had nothing — safe to start one + } else { + null // nobody answered; we know nothing about what is published + } + return newest.decrypt(account.signer) + } + + /** + * Merges [patch] into the published Invite List and republishes it, returning whether it landed. + * + * Read-merge-write, and **aborts rather than overwriting** when the read fails: the list is + * replaceable, so publishing a patch-only document over an unread list deletes every other + * link's `signer_sk` — unrecoverable, and it strands every holder of those links at the next + * rotation. A momentarily unreachable relay or a bunker signer that declines one decrypt is + * enough to trigger that, which is exactly how the kind-13302 community list was once emptied. + */ + private suspend fun publishConcordInviteList(patch: ConcordInviteListDocument): Boolean { + val publishTo = account.outboxRelays.flow.value + if (publishTo.isEmpty()) return false + val base = + readConcordInviteList() ?: run { + Log.w("Concord") { "Refusing to write the invite list: could not read the current one (would drop other links' signer_sk)" } + return false + } + // publishAndConfirm, never publish: `INostrClient.publish` returns Unit — it queues the event + // and never reports acceptance — so a `runCatching { publish(); true }` is true whenever + // local signing worked, and every caller's "did the record land?" gate becomes decorative. + return runCatching { + account.client.publishAndConfirm(ConcordInviteListEvent.create(account.signer, ConcordInviteList.merge(base, patch), TimeUtils.now()), publishTo) + }.onFailure { Log.w("Concord", "invite list publish failed", it) }.getOrDefault(false) + } + + /** + * Re-posts every live link this account minted for [entry]'s community at its own coordinate, + * carrying [entry]'s epoch (CORD-05). The kind-33301 bundle is addressable and authored by the + * link signer, so this moves the link behind the same URL instead of orphaning it at a dead + * epoch — which is the whole premise stranded recovery rests on. + * + * [entry] MUST be the post-rotation entry, passed in rather than re-read: the joined-list flow + * decrypts asynchronously, so reading it straight after adopting a new root yields the OLD + * epoch and would re-mint every link onto the epoch we just left. + * + * Each link is refreshed from its own CURRENT bundle, not rebuilt from scratch, so per-link + * fields the bundle carries — expiry, channel grants, icon, label — survive the rotation. A + * coordinate whose newest event is a revocation tombstone is left alone: re-posting a live + * bundle over it would silently un-revoke the link. + */ + private suspend fun refreshConcordInviteLinks(entry: ConcordCommunityListEntry): Int { + val relays = entry.relays.mapNotNullTo(mutableSetOf()) { RelayUrlNormalizer.normalizeOrNull(it) }.ifEmpty { account.outboxRelays.flow.value } + if (relays.isEmpty()) return 0 + val list = readConcordInviteList() ?: return 0 + val tombstoned = list.tombstones.mapTo(HashSet()) { it.token } + val now = TimeUtils.now() + + // An elapsed or retired link can no longer be joined; re-posting it would only resurrect a + // dead URL at a live epoch. + val links = list.entries.filter { it.communityId == entry.id && !it.isExpired(now) && it.token !in tombstoned } + if (links.isEmpty()) return 0 + + // One REQ for every link's bundle rather than a round trip each. This runs inside the + // user-visible Refounding, and a serial fetch per link makes a removal take time linear in + // how many links the creator ever minted, each able to wait out its own idle timeout. + val byAuthor = links.associateBy { it.signerPubKeyHex().lowercase() } + val wraps = account.client.fetchAll(filters = relays.associateWith { listOf(ConcordActions.bundlesFilter(byAuthor.keys.toList())) }) + val wrapsByAuthor = wraps.groupBy { it.pubKey.lowercase() } + + return coroutineScope { + byAuthor + .map { (author, link) -> + async { + runCatching { + val token = link.token.hexToByteArray() + // Classify per coordinate, never over the pooled set: one link's newer + // revocation tombstone must not decide another link's status. + val current = ConcordActions.classifyInvite(wrapsByAuthor[author].orEmpty(), token) as? InviteBundleStatus.Live ?: return@runCatching false + val moved = + current.invite.copy( + communityRoot = entry.root, + rootEpoch = entry.rootEpoch, + controlPk = entry.controlPk, + relays = entry.relays, + ) + // Confirmed: a link counted as moved but never stored is a link its + // holders can no longer redeem, reported as a success. + account.client.publishAndConfirm(ConcordActions.remintBundleAt(link.signerSk.hexToByteArray(), token, moved, now), relays) + }.onFailure { Log.w("Concord", "invite refresh failed for ${entry.id}", it) }.getOrDefault(false) + } + }.awaitAll() + .count { it } + } + } + /** * Mint a shareable invite link for a joined community and publish its * kind-33301 public bundle to the community relays. Returns the `…/invite/…` @@ -171,6 +320,21 @@ class AccountConcordActions( val entry = account.concordChannelList.liveCommunities.value .firstOrNull { it.id == communityId } ?: return null + // CREATE_INVITE, and not while banned. This used to check only that we held the community, + // which made minting the one moderation-free action in the app: a member the owner had just + // banned could tap the invite button and hand out a working link to the community they were + // removed from, and every account they invited arrived as a fresh un-banned npub. + // + // Note the bit is not otherwise enforced anywhere. The fold gates the INVITE_* Control + // entities on CREATE_INVITE, but a link's bundle is a standalone kind-33301 published + // OUTSIDE the Control Plane, so no fold ever sees it. This check is the only one there is. + // The owner is proven by the community id (CORD-02), so they are read off the entry and can + // mint before the session exists — the session is built asynchronously off the joined list, + // and requiring it here would have made the owner's own invite button fail on a cold start. + // Everyone else needs the folded roster, so no session means no invite. + val session = account.concordSessions.sessionFor(communityId) + val amOwner = entry.owner.equals(account.signer.pubKey, ignoreCase = true) + if (!amOwner && (session == null || !isAuthorizedFor(session, ConcordPermissions.CREATE_INVITE))) return null val invite = ConcordActions.inviteFor( communityIdHex = entry.id, @@ -187,10 +351,103 @@ class AccountConcordActions( val minted = ConcordActions.mintInviteLink(base, invite, TimeUtils.now(), entry.relays) val publishTo = entry.relays.mapNotNullTo(mutableSetOf()) { RelayUrlNormalizer.normalizeOrNull(it) }.ifEmpty { account.outboxRelays.flow.value } + // Record the link BEFORE handing the URL out (CORD-05, kind 13303). A link whose `signer_sk` + // was never stored can never be refreshed, so the next Refounding orphans it and everyone + // holding it is stranded — with nothing to have warned them. Failing the mint is the honest + // outcome; a stored entry for a link nobody received is harmless by comparison. + if (!publishConcordInviteList( + ConcordInviteListDocument( + entries = + listOf( + ConcordInviteListEntry( + token = minted.token.toHexKey(), + signerSk = minted.linkSignerPrivKey.toHexKey(), + communityId = entry.id, + url = minted.url, + createdAt = TimeUtils.now(), + ), + ), + ), + ) + ) { + Log.w("Concord") { "Invite not minted for ${entry.id}: its link signer could not be recorded, so the link could never be refreshed" } + return null + } + if (publishTo.isNotEmpty()) account.client.publish(minted.bundleEvent, publishTo) return minted.url } + /** + * Every link this account minted for [communityId] that is still live, newest first — the + * backing list for the invite-links screen. + * + * Null means the list could not be read (no relay answered, or the signer refused the decrypt), + * which the UI must show as an error rather than as "you have no links": telling a creator their + * leaked link doesn't exist is worse than telling them we couldn't check. + * + * Retired tokens are filtered out here rather than rendered as dead rows — [ConcordInviteList] + * already drops a tombstoned entry on merge, so a tombstoned entry only appears in the window + * between our revoke and the next merge. + */ + suspend fun listConcordInviteLinks(communityId: String): List? { + val list = readConcordInviteList() ?: return null + val tombstoned = list.tombstones.mapTo(HashSet()) { it.token } + return list.entries + .filter { it.communityId == communityId && it.token !in tombstoned } + .sortedByDescending { it.createdAt } + } + + /** + * Retires the link [token] (CORD-05 §2): publishes a `vsk=9` tombstone at its coordinate, then + * records the retirement in the kind-13303 list. Returns false if the link could not be retired. + * + * No community permission is checked, deliberately. The coordinate is authored by the link + * signer, whose secret only the creator holds, so revoking is an act on your own key rather than + * on the community — and gating it on CREATE_INVITE would mean a demoted admin could no longer + * retire the links they had already handed out, which is precisely when they most need to. + * + * The wire tombstone goes first and the list second. That is the inverse of minting and it is + * deliberate: the entry holds the only copy of the `signer_sk` this needs, and a merge drops a + * tombstoned token's entry terminally, so recording first and then failing to publish would + * leave the link live with its signer gone and no way left to retire it. A failed list write is + * recoverable — the link is already dead on the wire, and the refresh path re-mints only a + * coordinate that still resolves Live. + */ + suspend fun revokeConcordInvite( + communityId: String, + token: String, + ): Boolean { + if (!account.isWriteable()) return false + val entry = + account.concordChannelList.liveCommunities.value + .firstOrNull { it.id == communityId } ?: return false + val link = + readConcordInviteList()?.entries?.firstOrNull { it.token == token && it.communityId == communityId } + ?: run { + Log.w("Concord") { "Cannot revoke $token: it is not in this account's invite list, so its link signer is unknown" } + return false + } + + val relays = entry.relays.mapNotNullTo(mutableSetOf()) { RelayUrlNormalizer.normalizeOrNull(it) }.ifEmpty { account.outboxRelays.flow.value } + if (relays.isEmpty()) return false + // Confirmed, not fire-and-forget. A `publish` that returns Unit would report success for a + // tombstone no relay stored — and the list write below would then drop this entry on merge, + // destroying the only `signer_sk` that could ever retire the link while the link stays live. + val published = + runCatching { + account.client.publishAndConfirm(ConcordActions.revokeBundleAt(link.signerSk.hexToByteArray(), TimeUtils.now()), relays) + }.onFailure { Log.w("Concord", "invite revocation failed for $communityId", it) }.getOrDefault(false) + if (!published) return false + + if (!publishConcordInviteList(ConcordInviteListDocument(tombstones = listOf(ConcordInviteListTombstone(token = token, communityId = communityId))))) { + // The link is already dead on the wire, so this is bookkeeping we can retry rather than a + // failed revocation. Reported as success for exactly that reason. + Log.w("Concord") { "Revoked $token on the wire but could not tombstone it in the invite list; a later revoke will record it" } + } + return true + } + /** Drop a joined Concord community from the private kind-13302 list by its id. */ suspend fun leaveConcordCommunity(communityId: String) = account.sendMyPublicAndPrivateOutbox(account.concordChannelList.unfollow(communityId)) @@ -253,6 +510,42 @@ class AccountConcordActions( return ConcordInviteResult.Joined(bundle.communityId) } + // Refuse a link that readmits us after we were removed. A Refounding re-mints every + // outstanding link onto the new root (CORD-05), and an ex-member keeps the URL and its + // unlock token forever — so without this the rotation meant to expel them hands them the new + // keys instead. `recoverStrandedConcordCommunities` has always been ban-gated; this is the + // other door into the same room. + // + // Fails CLOSED on an unreadable plane: the banlist is only knowable once the bundle yields + // the root, and no verdict means no join. Two things make that safe to insist on rather than + // a way to brick valid invites: + // + // - the plane is fetched over the SAME relays that just served the bundle, not the relay + // list inside the bundle alone, which can be stale (a moved relay, a link minted before a + // relay change) and would otherwise refuse a community we can plainly reach; + // - it is PAGED, because a single REQ is truncated at the relay's per-filter cap. A missing + // older ban edition fails the gate open — it re-admits the very account it exists to + // refuse — so the one direction we must not economise on is completeness. + val joinKeys = + ConcordActions.controlPlaneKeys( + communityRoot = bundle.communityRoot.hexToByteArray(), + communityId = bundle.communityId.hexToByteArray(), + rootEpoch = bundle.rootEpoch, + controlPk = bundle.controlPk, + ) + // Union, not `ifEmpty`: the relays that served the bundle are known-good for this community, + // and the bundle's own list is the one that goes stale. + val joinRelays = bundle.relays.mapNotNullTo(mutableSetOf()) { RelayUrlNormalizer.normalizeOrNull(it) } + relays + val planeWraps = mutableListOf() + account.client.fetchAllPagesFromPool( + filters = joinRelays.associateWith { listOf(ConcordActions.planeFilter(joinKeys.address)) }, + ) { event, _ -> planeWraps.add(event) } + val joinEditions = ConcordActions.controlEditions(planeWraps, joinKeys) + if (joinEditions.isEmpty()) return ConcordInviteResult.NotReachable + if (AuthorityResolver.resolve(joinEditions, bundle.owner).isBanned(account.signer.pubKey)) { + return ConcordInviteResult.Banned + } + val entry = ConcordCommunityListEntry( id = bundle.communityId, @@ -430,7 +723,17 @@ class AccountConcordActions( channelIdHex: String, ) { if (!account.isWriteable()) return - val entry = account.concordSessions.sessionFor(communityId)?.entry ?: return + val session = account.concordSessions.sessionFor(communityId) ?: return + // A ban hides every message we send, so continuing to announce that we are typing them is + // both noise and a contradiction of what the ban told the room. Filtered on the receive side + // too (ConcordCommunitySession.ingestTyping) — a malicious client would keep sending. + if (session.state.value + ?.authority + ?.isBanned(account.signer.pubKey) == true + ) { + return + } + val entry = session.entry val channelKey = ConcordActions.publicChannel(entry.root.hexToByteArray(), channelIdHex.hexToByteArray(), entry.rootEpoch) val wrap = ConcordActions.buildChannelTyping(account.signer, channelKey, channelIdHex, entry.rootEpoch, TimeUtils.now()) val relays = entry.relays.mapNotNullTo(mutableSetOf()) { RelayUrlNormalizer.normalizeOrNull(it) } @@ -487,6 +790,49 @@ class AccountConcordActions( return cp } + /** + * Whether this account may take the action guarded by [bit] in [session] — and, when [target] is + * given, take it *against that member* (CORD-04 §3's rank rule, "equal cannot act on equal"). + * + * Every moderation verb below funnels through this. It used to live only in the composables that + * drew the buttons, which failed three ways: the screens tested `effectivePermissions`, which + * ignores the banlist, so a banned staffer still saw the controls; a verb reached from anywhere + * else (desktop, `amy`, a new screen) inherited no check at all; and holding `control_root` — + * a spam gate, never authority (CORD-02 §5) — was the only thing actually being enforced. + * + * Fails **closed**, with one deliberate exception: the owner is read from [ConcordCommunityListEntry] + * rather than from the fold, because the community id proves them (CORD-02) and they must stay able + * to moderate before their Control Plane has finished folding — or through a fold a rogue has + * damaged. Everyone else needs a resolved roster, so an unfolded community grants nobody else + * anything. + */ + private fun isAuthorizedFor( + session: ConcordCommunitySession, + bit: Int, + target: HexKey? = null, + ): Boolean { + val me = account.signer.pubKey + if (session.entry.owner.equals(me, ignoreCase = true)) return true + val authority = session.state.value?.authority ?: return false + // hasPermission, never effectivePermissions: the latter reads the roles alone and would let a + // banned staffer keep acting for as long as they hold the key. + val allowed = if (target == null) authority.hasPermission(me, bit) else authority.canActOn(me, target, bit) + if (!allowed) { + Log.w("Concord") { "Refusing a Concord action in ${session.entry.id}: not authorized for bit $bit${target?.let { " on $it" } ?: ""} (CORD-04 §3)" } + } + return allowed + } + + /** [controlKeysForWrite] gated by [isAuthorizedFor] — the standing check and the key check together. */ + private fun controlKeysForAction( + session: ConcordCommunitySession, + bit: Int, + target: HexKey? = null, + ): ControlPlaneKeys? { + if (!isAuthorizedFor(session, bit, target)) return null + return controlKeysForWrite(session) + } + /** Grant [member] exactly [roleIds] (empty list revokes their roles). */ suspend fun grantConcordRole( communityId: String, @@ -495,7 +841,7 @@ class AccountConcordActions( ): Boolean { val session = account.concordSessions.sessionFor(communityId) ?: return false if (!account.isWriteable()) return false - val cp = controlKeysForWrite(session) ?: return false + val cp = controlKeysForAction(session, ConcordPermissions.MANAGE_ROLES, member) ?: return false // A Grant that first makes its member staff must deliver the control_root in the same // edition (CORD-04 §3) — grantWithStaffDelivery attaches the pairwise wrap when the // roles carry a Control-writing bit and we hold the secret to hand over. @@ -567,7 +913,7 @@ class AccountConcordActions( ): Boolean { val session = account.concordSessions.sessionFor(communityId) ?: return false if (!account.isWriteable()) return false - val cp = controlKeysForWrite(session) ?: return false + val cp = controlKeysForAction(session, ConcordPermissions.MANAGE_ROLES, member) ?: return false val existing = session.state.value @@ -609,7 +955,7 @@ class AccountConcordActions( ): Boolean { val session = account.concordSessions.sessionFor(communityId) ?: return false if (!account.isWriteable()) return false - val cp = controlKeysForWrite(session) ?: return false + val cp = controlKeysForAction(session, ConcordPermissions.MANAGE_ROLES, member) ?: return false val grantWrap = ConcordModeration.grant(account.signer, cp, communityId.hexToByteArray(), member, emptyList(), session.controlEditions(), TimeUtils.now(), owner = session.entry.owner) publishConcordWrap(session.entry, grantWrap) return true @@ -657,7 +1003,7 @@ class AccountConcordActions( ): Boolean { val session = account.concordSessions.sessionFor(communityId) ?: return false if (!account.isWriteable()) return false - val cp = controlKeysForWrite(session) ?: return false + val cp = controlKeysForAction(session, ConcordPermissions.BAN, member) ?: return false val wrap = ConcordModeration.ban(account.signer, cp, communityId.hexToByteArray(), member, session.controlEditions(), TimeUtils.now(), owner = session.entry.owner) publishConcordWrap(session.entry, wrap) return true @@ -670,7 +1016,7 @@ class AccountConcordActions( ): Boolean { val session = account.concordSessions.sessionFor(communityId) ?: return false if (!account.isWriteable()) return false - val cp = controlKeysForWrite(session) ?: return false + val cp = controlKeysForAction(session, ConcordPermissions.BAN, member) ?: return false val wrap = ConcordModeration.unban(account.signer, cp, communityId.hexToByteArray(), member, session.controlEditions(), TimeUtils.now(), owner = session.entry.owner) publishConcordWrap(session.entry, wrap) return true @@ -701,10 +1047,22 @@ class AccountConcordActions( val session = account.concordSessions.sessionFor(communityId) ?: return false val state = session.state.value ?: return false val authority = state.authority - val iCanBan = authority.isOwner(account.signer.pubKey) || authority.effectivePermissions(account.signer.pubKey).has(ConcordPermissions.BAN) + // hasPermission, not effectivePermissions: a Refounding is the hardest action in the protocol + // and this guard used to ignore the banlist, so a banned BAN-holder could launch one from the + // shipping app. Honest receivers refuse such a rotation (drainConcordRekeys checks the same + // ban-aware predicate), but that is a race against banlist propagation, not a check. + val iCanBan = authority.isOwner(account.signer.pubKey) || authority.hasPermission(account.signer.pubKey, ConcordPermissions.BAN) if (!iCanBan) return false val removedLower = removed.mapTo(HashSet()) { it.lowercase() } if (removedLower.isEmpty() || removedLower.any { authority.isOwner(it) }) return false + // Removal is the hardest form of a ban, so it takes the same rank rule (CORD-04 §3): an admin + // cannot Refound a peer admin out of the community any more than they could ban one. The owner + // short-circuits, as everywhere else, because canActOn starts at hasPermission. + if (!authority.isOwner(account.signer.pubKey) && + removedLower.any { !authority.canActOn(account.signer.pubKey, it, ConcordPermissions.BAN) } + ) { + return false + } // A Refounding writes the current plane (the pre-rotation bans) and the new one (the // compaction), so on a split epoch it takes the current control_root (CORD-02 §2). A // rank-qualified refounder whose secret hasn't arrived yet must wait for re-delivery. @@ -735,7 +1093,7 @@ class AccountConcordActions( .apply { removeAll(removedLower) removeAll(authority.bannedMembers()) - }.toList() + }.let { candidates -> boundRecipients(candidates, authority) } // 3. Build the refounding: new root, compacted Control Plane, per-recipient rekey blobs. val entry = session.entry @@ -762,6 +1120,7 @@ class AccountConcordActions( recipientsXOnly = recipients, staffXOnly = staff, createdAt = TimeUtils.now(), + ownerPubKey = entry.owner, ) // 4. Publish the compacted Control Plane (the new epoch's state) then the rekey blobs @@ -774,10 +1133,60 @@ class AccountConcordActions( // 5. Adopt the new epoch ourselves. This rebuilds our session under the new root and // re-folds the compacted Control Plane (with the ban), dropping the removed members. - adoptConcordRoot(entry, newRoot, build.newEpoch, build.newControlKeys.address.hexToByteArray(), newControlRoot) + val adopted = adoptConcordRoot(entry, newRoot, build.newEpoch, build.newControlKeys.address.hexToByteArray(), newControlRoot) + + // 6. Move every link we minted to the new epoch. Without this the Refounding orphans them, + // and a member it left out — no rekey blob, no message to miss — has no way back at all. + // Uses the entry adoption just wrote: `liveCommunities` decrypts asynchronously, so + // reading it here would hand us the epoch we just left and re-mint every link onto it. + val moved = adopted?.let { refreshConcordInviteLinks(it) } ?: 0 + Log.i("Concord") { "Refounding ${entry.id}: refreshed $moved invite link(s) to epoch ${build.newEpoch}" } return true } + /** + * Caps the Refounding recipient set, keeping the members whose standing we can actually vouch + * for when there are too many. + * + * `allMembers()` is the Guestbook ∪ `observedAuthors` ∪ the roster, and the first two are + * unbounded and attacker-writable: a Guestbook Join is self-signed by any key at all, and every + * author we decrypt is folded in by design (CORD-02 §5, "observably present"). So each throwaway + * npub someone posts from, or simply announces, becomes one more mandatory blob in the next + * Refounding — meaning the attack inflates the cost of its own remedy, and the remedy is the only + * hard removal Concord has. See B4 in `docs/concord-soft-ban-audit.md`. + * + * The roster and the owner are kept unconditionally: they are owner-rooted, so they cannot be + * padded from outside. The remainder fills the budget, and anything dropped is **logged rather + * than silently truncated** — a dropped member is stranded on the dead epoch and their only way + * back is a recovery path that needs to know it happened. + */ + private fun boundRecipients( + candidates: Set, + authority: AuthorityResolver, + ): List { + if (candidates.size <= MAX_REFOUNDING_RECIPIENTS) return candidates.toList() + + // The roster goes in whole even if it alone exceeds the budget: it is owner-rooted, so it + // cannot be padded from outside, and dropping an admin to make room for a stranger inverts + // the point of the cap. + val vouched = authority.roleHolders() + authority.staffMembers() + val kept = LinkedHashSet() + candidates.filterTo(kept) { it in vouched } + for (candidate in candidates) { + if (kept.size >= MAX_REFOUNDING_RECIPIENTS) break + kept.add(candidate) + } + val dropped = candidates.size - kept.size + if (dropped > 0) { + Log.w("Concord") { + "Refounding recipient set trimmed to ${kept.size} of ${candidates.size} " + + "(budget $MAX_REFOUNDING_RECIPIENTS, roster kept whole): $dropped member(s) will be " + + "stranded on the prior epoch" + } + } + return kept.toList() + } + // Rotations we've already adopted ("communityId:epoch"), so a base-rekey wrap still buffered // in the pre-rebuild window (the session rebuild off `liveCommunities` is async) is not // adopted — and re-published — twice on successive revision ticks. @@ -796,39 +1205,16 @@ class AccountConcordActions( newEpoch: Long, newControlPk: ByteArray? = null, newControlRoot: ByteArray? = null, - ) { - if (!adoptedConcordRotations.add("${entry.id}:$newEpoch")) return - // The epoch we're leaving is banked with the address it was folded at, so its Control - // Plane stays subscribable for the anti-rollback floor (a split epoch's address can - // never be re-derived, only remembered — CORD-02 §2). - val held = (entry.heldRoots + HeldRoot(entry.rootEpoch, entry.root, entry.controlPk, entry.controlRoot)).distinctBy { it.epoch } - val next = - ConcordCommunityListEntry( - id = entry.id, - owner = entry.owner, - ownerSalt = entry.ownerSalt, - root = newRoot.toHexKey(), - rootEpoch = newEpoch, - // A rotation that delivered no control material is a legacy, pre-split one - // (CORD-06 §3): the new epoch keeps folding at the legacy address, and the - // stale prior-epoch values must NOT be carried into it. - controlPk = newControlPk?.toHexKey(), - controlRoot = newControlRoot?.toHexKey(), - heldRoots = held, - privateChannels = entry.privateChannels, - relays = entry.relays, - name = entry.name, - addedAt = entry.addedAt, - // The invite_ref anchor must survive a rotation, or the *next* Refounding we're left - // out of would be unrecoverable. - inviteRef = entry.inviteRef, - excludedAtEpoch = entry.excludedAtEpoch, - // Unknown keys another client wrote (Armada's list is `[k: string]: unknown`) - // must survive our rotation write, or we delete their data on every rekey. - residue = entry.residue, - ) + ): ConcordCommunityListEntry? { + if (!adoptedConcordRotations.add("${entry.id}:$newEpoch")) return null + // The rewrite itself — banking the leaving epoch's address for the anti-rollback floor, + // dropping stale control material on a legacy rotation, preserving invite_ref and residue — + // is shared with `amy` in [ConcordReceive.withAdoptedRoot]. Only the persist + publish and + // the Guestbook re-announce below are Android's. + val next = ConcordReceive.withAdoptedRoot(entry, newRoot, newEpoch, newControlPk, newControlRoot) account.sendMyPublicAndPrivateOutbox(account.concordChannelList.follow(next)) announceConcordGuestbookJoin(next, inviteCreator = null, inviteLabel = null) + return next } /** @@ -874,7 +1260,17 @@ class AccountConcordActions( // who has themselves been banned could still rotate the whole community. val authorized = authority.isOwner(received.rotator) || authority.hasPermission(received.rotator, ConcordPermissions.BAN) if (!authorized) continue - adoptConcordRoot(entry, received.newRoot, received.newEpoch, received.newControlPk, received.newControlRoot) + val adopted = adoptConcordRoot(entry, received.newRoot, received.newEpoch, received.newControlPk, received.newControlRoot) + + // Move our own links onto the epoch we just adopted. Rotating is not the only way to end + // up on a new epoch — being re-keyed is the common one — and a link creator who is merely + // re-keyed would otherwise leave every link they handed out pointing at the dead root, + // which is exactly the orphaning this branch exists to stop. Stranded recovery reads the + // bundle's epoch, so a link nobody re-mints is a member nobody can recover. + adopted?.let { next -> + val moved = refreshConcordInviteLinks(next) + if (moved > 0) Log.i("Concord") { "Rekey ${next.id}: refreshed $moved invite link(s) to epoch ${received.newEpoch}" } + } } } @@ -896,39 +1292,16 @@ class AccountConcordActions( */ internal suspend fun drainConcordStaffGrants() { if (!account.isWriteable()) return - val me = account.signer.pubKey.lowercase() for (session in account.concordSessions.sessions()) { val entry = session.entry - // Already staff at this epoch, or a legacy community with no split to join. - val heldControlPk = entry.controlPk - if (entry.controlRoot != null || heldControlPk == null) continue val state = session.state.value ?: continue - // Only a Grant our fold honors can deliver: an unauthorized edition hands us nothing. - if (!state.authority.isStaff(me)) continue - - val myGrantCoordinate = - ConcordKeyDerivation - .grantCoordinate(entry.id.hexToByteArray(), me.hexToByteArray()) - .toHexKey() - val delivered = - session - .controlEditions() - .filter { it.entityKind == ControlEntityKind.GRANT && it.entityIdHex == myGrantCoordinate } - // Newest first: a re-issued Grant (a lost key, a head superseded before we - // fetched it) carries the fresher wrap. - .sortedByDescending { it.version } - .firstNotNullOfOrNull { edition -> - val wrap = ConcordJson.decodeOrNull(edition.content)?.controlWrap ?: return@firstNotNullOfOrNull null - val opened = ControlRootWrap.openOrNull(wrap, account.signer, edition.author) ?: return@firstNotNullOfOrNull null - if (opened.epoch != entry.rootEpoch) return@firstNotNullOfOrNull null - // Fails closed: a secret that doesn't derive to the pk we hold is dropped, - // never adopted — we will not split ourselves off from the plane's readers. - if (!ControlRootWrap.derivesTo(opened.controlRoot, entry.id.hexToByteArray(), entry.rootEpoch, heldControlPk)) return@firstNotNullOfOrNull null - opened.controlRoot - } ?: continue + // The whole decision — are we staff, does a Grant carry a wrap, does it open, name our + // epoch, and derive to the control_pk we hold — is shared with `amy` in + // [ConcordReceive.deliveredControlRoot]. Only the persist + publish below is Android's. + val delivered = ConcordReceive.deliveredControlRoot(entry, session.controlEditions(), state.authority, account.signer) ?: continue account.sendMyPublicAndPrivateOutbox( - account.concordChannelList.follow(entry.withControlRoot(delivered.toHexKey())), + account.concordChannelList.follow(entry.withControlRoot(delivered)), ) } } @@ -986,7 +1359,29 @@ class AccountConcordActions( // Only a live bundle recovers: an expired/revoked link is not a rotation we missed. val bundle = (ConcordActions.classifyInvite(wraps, parsed.fragment.token) as? InviteBundleStatus.Live)?.invite ?: continue - val merged = ConcordActions.recoverStranded(entry, bundle) ?: continue + // A removed member holds the link's unlock token forever, so without this the sweep + // walks them straight back into the epoch they were rotated out of — see A2 in + // docs/concord-soft-ban-audit.md. Read off the epoch we are LEAVING, which is the last + // one whose Control Plane we can still fold. + // + // Fails CLOSED. `?.isBanned(..) == true` reads "not banned" for a session that does not + // exist yet or whose first fold has not landed, and this sweep runs on the revision tick + // — so a banned member's own client would have hit that window on cold start and + // recovered itself, which is precisely the bypass this gate exists to stop. No verdict + // means no recovery; the next sweep retries once the roster is known. + val authority = + account.concordSessions + .sessionFor(entry.id) + ?.state + ?.value + ?.authority + if (authority == null) { + Log.i("Concord") { "Stranded-recovery check deferred for ${entry.id}: control plane not folded yet" } + lastConcordRecoveryCheck.remove(entry.id) + continue + } + val bannedHere = authority.isBanned(account.signer.pubKey) + val merged = ConcordActions.recoverStranded(entry, bundle, bannedHere) ?: continue if (!adoptedConcordRotations.add("${entry.id}:${merged.rootEpoch}")) continue Log.i("Concord", "Stranded recovery: ${entry.id} ${entry.rootEpoch} -> ${merged.rootEpoch}") account.sendMyPublicAndPrivateOutbox(account.concordChannelList.follow(merged)) @@ -1009,7 +1404,7 @@ class AccountConcordActions( ): Boolean { val session = account.concordSessions.sessionFor(communityId) ?: return false if (!account.isWriteable()) return false - val cp = controlKeysForWrite(session) ?: return false + val cp = controlKeysForAction(session, ConcordPermissions.MANAGE_METADATA) ?: return false val metadata = MetadataEntity(name = name, icon = icon, banner = banner, description = description, relays = relays) val wrap = ConcordModeration.editMetadata(account.signer, cp, communityId.hexToByteArray(), metadata, session.controlEditions(), TimeUtils.now(), owner = session.entry.owner) publishConcordWrap(session.entry, wrap) @@ -1027,7 +1422,7 @@ class AccountConcordActions( ): Boolean { val session = account.concordSessions.sessionFor(communityId) ?: return false if (!account.isWriteable()) return false - val cp = controlKeysForWrite(session) ?: return false + val cp = controlKeysForAction(session, ConcordPermissions.MANAGE_CHANNELS) ?: return false val channelId = RandomInstance.bytes(32) val channel = ChannelEntity(name = name.trim()) val wrap = ConcordModeration.defineChannel(account.signer, cp, channelId, channel, session.controlEditions(), TimeUtils.now(), owner = session.entry.owner) @@ -1043,7 +1438,7 @@ class AccountConcordActions( ): Boolean { val session = account.concordSessions.sessionFor(communityId) ?: return false if (!account.isWriteable()) return false - val cp = controlKeysForWrite(session) ?: return false + val cp = controlKeysForAction(session, ConcordPermissions.MANAGE_CHANNELS) ?: return false // Carry the standing definition forward and change only the name. A ChannelEntity built from // scratch defaults `private` and `voice` to false, so renaming a private channel used to // publish an edition declaring it PUBLIC — and a voice channel became a text channel. @@ -1066,7 +1461,7 @@ class AccountConcordActions( ): Boolean { val session = account.concordSessions.sessionFor(communityId) ?: return false if (!account.isWriteable()) return false - val cp = controlKeysForWrite(session) ?: return false + val cp = controlKeysForAction(session, ConcordPermissions.MANAGE_CHANNELS) ?: return false // Same as rename: preserve the standing flags so a tombstone does not also silently // reclassify the channel it retires. val standing = diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/ConcordInviteResult.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/ConcordInviteResult.kt index 6b4d2599cb..8f502e358b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/ConcordInviteResult.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/ConcordInviteResult.kt @@ -54,6 +54,15 @@ sealed interface ConcordInviteResult { */ data object Expired : ConcordInviteResult + /** + * The link opens, but this community's roster has banned us (CORD-04). + * + * A Refounding re-mints every outstanding link onto the new root, and a removed member keeps the + * URL and its unlock token forever — so honouring the link alone would hand the new keys to the + * very account the rotation expelled. + */ + data object Banned : ConcordInviteResult + /** * The bundle event was found but could not be opened with the link's token — * typically because it was minted by a newer/incompatible Concord client whose diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt index 9df341c771..4029469535 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt @@ -479,7 +479,7 @@ object LocalCache : ILocalCache, ICacheProvider, Dao { @Volatile var lnurlEndpointResolver: LnurlEndpointResolver? = null - val relayHints = HintIndexer() + override val relayHints = HintIndexer() /** * Cashu mint URL directory, populated passively as @@ -679,7 +679,7 @@ object LocalCache : ILocalCache, ICacheProvider, Dao { fun observeLatestNote(filter: Filter) = observeNotes(filter).map { it.firstOrNull() } - fun checkGetOrCreateUser(key: String): User? = runCatching { getOrCreateUser(key) }.getOrNull() + override fun checkGetOrCreateUser(key: String): User? = runCatching { getOrCreateUser(key) }.getOrNull() fun load(keys: List): List = keys.mapNotNull(::checkGetOrCreateUser) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/indexerRelays/IndexerRelayListState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/indexerRelays/IndexerRelayListState.kt index 40f30c1dbb..1491bee591 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/indexerRelays/IndexerRelayListState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/indexerRelays/IndexerRelayListState.kt @@ -62,6 +62,28 @@ class IndexerRelayListState( suspend fun normalizeIndexerRelayListWithBackupNoDefaults(note: Note): Set = indexListEvent(note)?.let { decryptionCache.relays(it) } ?: emptySet() + /** + * Same resolution as [normalizeIndexerRelayListWithBackup] but non-suspending, for use as the + * [flow] seed. Reads the event's public tags plus any *already decrypted* private tags; it + * never asks the signer, so it cannot block or hit a NIP-46 round trip. + * + * At login `indexerListNote.event` is usually still null and this resolves through + * `settings.backupIndexRelayList`, restored from LocalPreferences — so an account with public + * indexer relays gets its own relays immediately instead of the defaults. + */ + fun normalizeIndexerRelayListPrecached(note: Note): Set = indexListEvent(note)?.let { decryptionCache.cachedRelays(it) }?.ifEmpty { null } ?: DefaultIndexerRelayList + + /** + * The account's indexer relays, **never empty** — [normalizeIndexerRelayListWithBackup] + * substitutes [DefaultIndexerRelayList] both when there is no kind:10086 and when the + * one we have decodes to zero relays. Callers assembling metadata / relay-list REQs read + * this and can rely on getting a usable set; use [flowNoDefaults] instead to show or diff + * what the user actually configured. + * + * Seeded via [normalizeIndexerRelayListPrecached] rather than `emptySet()`, for the same + * reason as the search list: `flowOn(IO)` makes the first real emission asynchronous, so an + * `emptySet()` seed left a window where `.value` contradicted the contract above. + */ val flow = getIndexerRelayListFlow() .map { normalizeIndexerRelayListWithBackup(it.note) } @@ -70,7 +92,7 @@ class IndexerRelayListState( .stateIn( scope, SharingStarted.Eagerly, - emptySet(), + normalizeIndexerRelayListPrecached(indexerListNote), ) val flowNoDefaults = diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/searchRelays/SearchRelayListState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/searchRelays/SearchRelayListState.kt index 3a227ef306..eb3714dc5c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/searchRelays/SearchRelayListState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/searchRelays/SearchRelayListState.kt @@ -62,6 +62,31 @@ class SearchRelayListState( suspend fun normalizeSearchRelayListWithBackupNoDefaults(note: Note): Set = searchListEvent(note)?.let { decryptionCache.relays(it) } ?: emptySet() + /** + * Same resolution as [normalizeSearchRelayListWithBackup] but non-suspending, for use as the + * [flow] seed. Reads the event's public tags plus any *already decrypted* private tags; it + * never asks the signer, so it cannot block or hit a NIP-46 round trip. + * + * At login `searchListNote.event` is usually still null and this resolves through + * `settings.backupSearchRelayList`, restored from LocalPreferences — so an account with public + * search relays gets its own relays immediately instead of the defaults. Accounts whose relays + * are exclusively private fall back to [DefaultSearchRelayList] until the first decrypt lands. + */ + fun normalizeSearchRelayListPrecached(note: Note): Set = searchListEvent(note)?.let { decryptionCache.cachedRelays(it) }?.ifEmpty { null } ?: DefaultSearchRelayList + + /** + * The account's search relays, **never empty** — [normalizeSearchRelayListWithBackup] + * substitutes [DefaultSearchRelayList] both when there is no kind:10007 and when the + * one we have decodes to zero relays. Callers assembling NIP-50 REQs read this and can + * rely on getting a usable set; use [flowNoDefaults] instead to show or diff what the + * user actually configured. + * + * Seeded via [normalizeSearchRelayListPrecached] rather than `emptySet()`: `flowOn(IO)` means + * the first real emission can never be synchronous with `stateIn`, so an `emptySet()` seed + * left a window where `.value` contradicted the "never empty" contract above and search + * silently queried nothing. That window is unbounded for a NIP-46 signer whose list has + * private entries, since the first emission waits on a remote decrypt. + */ val flow = getSearchRelayListFlow() .map { normalizeSearchRelayListWithBackup(it.note) } @@ -70,7 +95,7 @@ class SearchRelayListState( .stateIn( scope, SharingStarted.Eagerly, - emptySet(), + normalizeSearchRelayListPrecached(searchListNote), ) val flowNoDefaults = diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/DataStoreNappletStorage.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/DataStoreNappletStorage.kt index 893f02ec87..14d3499371 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/DataStoreNappletStorage.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/DataStoreNappletStorage.kt @@ -57,7 +57,27 @@ class DataStoreNappletStorage( key: String, value: String, ) { - dataStore.edit { it[keyOf(coordinate, key)] = value } + dataStore.edit { preferences -> + val prefix = prefixOf(coordinate) + val target = keyOf(coordinate, key) + val currentBytes = + preferences + .asMap() + .entries + .asSequence() + .filter { it.key.name.startsWith(prefix) } + .sumOf { (storedKey, storedValue) -> + storedKey.name + .removePrefix(prefix) + .encodeToByteArray() + .size + + ((storedValue as? String)?.encodeToByteArray()?.size ?: 0) + } + val replacedBytes = key.encodeToByteArray().size + (preferences[target]?.encodeToByteArray()?.size ?: 0) + val proposedBytes = currentBytes - replacedBytes + key.encodeToByteArray().size + value.encodeToByteArray().size + require(proposedBytes <= MAX_STORAGE_BYTES) { "Napplet storage quota exceeded." } + preferences[target] = value + } } override suspend fun remove( @@ -87,4 +107,9 @@ class DataStoreNappletStorage( coordinate: String, key: String, ) = stringPreferencesKey(prefixOf(coordinate) + key) + + companion object { + /** NAP-STORAGE's recommended per-napplet UTF-8 quota. */ + const val MAX_STORAGE_BYTES = 512 * 1024 + } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletBrokerService.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletBrokerService.kt index 37a124c67e..f49b497538 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletBrokerService.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletBrokerService.kt @@ -51,6 +51,7 @@ import com.vitorpamplona.amethyst.napplethost.NappletIpc import com.vitorpamplona.amethyst.ui.MainActivity import com.vitorpamplona.quartz.nip01Core.core.HexKey import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.CoroutineStart import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.SupervisorJob @@ -58,6 +59,7 @@ import kotlinx.coroutines.cancel import kotlinx.coroutines.delay import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch +import java.util.concurrent.ConcurrentHashMap /** * The trust boundary's main-process endpoint. The untrusted `:napplet` process binds this @@ -93,7 +95,11 @@ class NappletBrokerService : Service() { // Live relay subscriptions, keyed by the applet's subId. The account comes per-open from the // requesting surface's launch token, so a surface's REQs always target the account it acts as. - private val liveSubscriptions = NappletLiveSubscriptions() + private val liveSubscriptions = NappletLiveSubscriptions(scope) + + // NAP-RESOURCE cancellation is keyed by the trusted launch token plus the caller's request id. + // Cancelling removes the job before it can emit a late terminal envelope to the sandbox. + private val resourceRequests = ConcurrentHashMap() // The app-wide inc pub/sub bus: routes inc.emit between live napplet sessions as inc.event pushes. private val incBus = NappletIncBus { replyTo, payload -> push(replyTo, payload) } @@ -117,7 +123,7 @@ class NappletBrokerService : Service() { override fun onDestroy() { liveSubscriptions.closeAll() - identityWatch.stop() + identityWatch.stopAll() // Every applet/browser surface has unbound, so the "session" the user granted for is over. // The ledger and the broker cache are now app-wide singletons that outlive this service, so // their in-memory session grants have to be dropped explicitly here — that keeps the lifetime @@ -280,38 +286,57 @@ class NappletBrokerService : Service() { // Resolve the launch token to the trusted identity + declared set. The sandbox never states // its own coordinate, so a compromised :napplet process can only ever act as the napplet it // was launched as (it holds only its own token). An unknown token = no session; refuse. - val session = NappletLaunchRegistry.resolve(data.getString(NappletIpc.KEY_LAUNCH_TOKEN)) + val launchToken = data.getString(NappletIpc.KEY_LAUNCH_TOKEN) + val session = NappletLaunchRegistry.resolve(launchToken) if (session == null) { reply(replyTo, requestId, NappletProtocolJson.encodeResponse(requestType, NappletResponse.Failed("Unknown napplet session."))) return true } val identity = session.identity val declared = session.declared + val resourceRequestKey = "$launchToken\u0000$requestId" + if (requestType == "resource.cancel") { + resourceRequests.remove(resourceRequestKey)?.cancel() + return true + } + val tracksResourceRequest = requestType == "resource.bytes" || requestType == "resource.bytesMany" - scope.launch { - // The shared, host-agnostic router owns decode → broker → encode and the subscribe-vs-reply - // decision (it stays wire-identical with the future desktop host). This service only supplies - // the broker, the Messenger transport, and the live relay subscription each Outcome implies. - // The launch token decides whose key signs — not the active account. A surface opened by - // one account can never be handed another's signer, even while it stays open across a switch. - val broker = brokerFor(session.accountPubKey) - if (broker == null) { - reply(replyTo, requestId, NappletProtocolJson.encodeResponse(requestType, NappletResponse.Failed("That account is no longer signed in."))) - return@launch - } - when (val outcome = NappletRequestRouter.route(broker, identity, declared, payload)) { - is NappletRequestRouter.Outcome.Ignore -> {} - is NappletRequestRouter.Outcome.Reply -> reply(replyTo, requestId, outcome.payload) - is NappletRequestRouter.Outcome.OpenSubscription -> - liveSubscriptions.open(outcome.subId, outcome.filters, accountFor(session.accountPubKey)) { push(replyTo, it) } - is NappletRequestRouter.Outcome.CloseSubscription -> liveSubscriptions.close(outcome.subId) - is NappletRequestRouter.Outcome.WatchIdentity -> identityWatch.start(session.accountPubKey) { push(replyTo, it) } - is NappletRequestRouter.Outcome.UnwatchIdentity -> identityWatch.stop() - is NappletRequestRouter.Outcome.Push -> outcome.payloads.forEach { push(replyTo, it) } - is NappletRequestRouter.Outcome.SubscribeInc -> incBus.subscribe(replyTo, outcome.topic) - is NappletRequestRouter.Outcome.UnsubscribeInc -> incBus.unsubscribe(replyTo, outcome.topic) - is NappletRequestRouter.Outcome.EmitInc -> incBus.emit(replyTo, identity.coordinate, outcome.topic, outcome.payloadRaw) + val requestJob = + scope.launch(start = if (tracksResourceRequest) CoroutineStart.LAZY else CoroutineStart.DEFAULT) { + // The shared, host-agnostic router owns decode → broker → encode and the subscribe-vs-reply + // decision (it stays wire-identical with the future desktop host). This service only supplies + // the broker, the Messenger transport, and the live relay subscription each Outcome implies. + // The launch token decides whose key signs — not the active account. A surface opened by + // one account can never be handed another's signer, even while it stays open across a switch. + val broker = brokerFor(session.accountPubKey) + if (broker == null) { + reply(replyTo, requestId, NappletProtocolJson.encodeResponse(requestType, NappletResponse.Failed("That account is no longer signed in."))) + return@launch + } + when (val outcome = NappletRequestRouter.route(broker, identity, declared, payload)) { + is NappletRequestRouter.Outcome.Ignore -> {} + is NappletRequestRouter.Outcome.Reply -> { + reply(replyTo, requestId, outcome.payload) + // NAP-IDENTITY has no watch/unwatch request. Once the consent-gated startup + // snapshot succeeds, the runtime owns identity.changed delivery for this + // trusted launch token until the broker service closes. + if (requestType == "identity.getPublicKey" && outcome.response is NappletResponse.PublicKey && launchToken != null) { + identityWatch.start(launchToken, session.accountPubKey) { push(replyTo, it) } + } + } + is NappletRequestRouter.Outcome.OpenSubscription -> + liveSubscriptions.open(outcome.subId, outcome.filters, accountFor(session.accountPubKey)) { push(replyTo, it) } + is NappletRequestRouter.Outcome.CloseSubscription -> liveSubscriptions.close(outcome.subId) + is NappletRequestRouter.Outcome.Push -> outcome.payloads.forEach { push(replyTo, it) } + is NappletRequestRouter.Outcome.SubscribeInc -> incBus.subscribe(replyTo, outcome.topic) + is NappletRequestRouter.Outcome.UnsubscribeInc -> incBus.unsubscribe(replyTo, outcome.topic) + is NappletRequestRouter.Outcome.EmitInc -> incBus.emit(replyTo, identity.coordinate, outcome.topic, outcome.payloadRaw) + } } + if (tracksResourceRequest) { + resourceRequests.put(resourceRequestKey, requestJob)?.cancel() + requestJob.invokeOnCompletion { resourceRequests.remove(resourceRequestKey, requestJob) } + requestJob.start() } return true } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletCapabilityLabels.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletCapabilityLabels.kt index adff3b46f6..15ab1abd71 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletCapabilityLabels.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletCapabilityLabels.kt @@ -28,7 +28,6 @@ import com.vitorpamplona.amethyst.commons.napplet.NappletCapability @StringRes fun NappletCapability.labelRes(): Int = when (this) { - NappletCapability.SHELL -> R.string.napplet_cap_shell NappletCapability.IDENTITY -> R.string.napplet_cap_identity NappletCapability.KEYS -> R.string.napplet_cap_keys NappletCapability.RELAY -> R.string.napplet_cap_relay @@ -45,7 +44,6 @@ fun NappletCapability.labelRes(): Int = @StringRes fun NappletCapability.descriptionRes(): Int = when (this) { - NappletCapability.SHELL -> R.string.napplet_cap_shell_desc NappletCapability.IDENTITY -> R.string.napplet_cap_identity_desc NappletCapability.KEYS -> R.string.napplet_cap_keys_desc NappletCapability.RELAY -> R.string.napplet_cap_relay_desc diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletConsentSummary.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletConsentSummary.kt index 42f4ec93f5..583cda40b4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletConsentSummary.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletConsentSummary.kt @@ -294,9 +294,10 @@ class NappletConsentSummary( context.getString(R.string.napplet_consent_pay_no_amount) } } - is NappletRequest.ResourceBytes -> context.getString(R.string.napplet_consent_resource) + NappletRequest.ResourceInfo, is NappletRequest.ResourceBytes, is NappletRequest.ResourceBytesMany -> + context.getString(R.string.napplet_consent_resource) is NappletRequest.UploadBlob -> context.getString(R.string.napplet_consent_upload) // Resolved in the broker before consent (negotiation / shell-mediated / cosmetic); never shown. - is NappletRequest.ShellSupports, is NappletRequest.RegisterAction, is NappletRequest.UnregisterAction, is NappletRequest.ThemeGet -> "" + is NappletRequest.RegisterAction, is NappletRequest.UnregisterAction, is NappletRequest.ThemeGet -> "" } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletIdentityWatch.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletIdentityWatch.kt index f94f7bc2cf..819c55b4a2 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletIdentityWatch.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletIdentityWatch.kt @@ -27,6 +27,7 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.drop import kotlinx.coroutines.launch +import java.util.concurrent.ConcurrentHashMap /** * Streams `identity.changed` pushes to an applet that registered `napplet.identity.onChanged`. It @@ -34,31 +35,35 @@ import kotlinx.coroutines.launch * value is dropped — the applet already has it via `getPublicKey`), encodes and pushes the new key * (or `""` when no account is signed in) to the caller-supplied sink. * - * One watch at a time per host binding; [start] replaces any prior one. Reached only after the - * router confirmed the applet declared the IDENTITY capability. + * Watches are keyed by the trusted launch token so concurrent surfaces cannot replace each other's + * streams. A watch starts only after that surface successfully obtains its public-key snapshot. */ class NappletIdentityWatch( private val scope: CoroutineScope, private val pubKey: (boundPubKey: String) -> Flow, ) { - private var job: Job? = null + private val jobs = ConcurrentHashMap() fun start( + watchId: String, boundPubKey: String, push: (String) -> Unit, ) { - stop() - job = - scope.launch { - pubKey(boundPubKey) - .distinctUntilChanged() - .drop(1) - .collect { push(NappletProtocolJson.encodeIdentityChanged(it)) } - } + jobs.computeIfAbsent(watchId) { id -> + scope + .launch { + pubKey(boundPubKey) + .distinctUntilChanged() + .drop(1) + .collect { push(NappletProtocolJson.encodeIdentityChanged(it)) } + }.also { job -> + job.invokeOnCompletion { jobs.remove(id, job) } + } + } } - fun stop() { - job?.cancel() - job = null + fun stopAll() { + jobs.values.forEach { it.cancel() } + jobs.clear() } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletLaunchRegistry.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletLaunchRegistry.kt index b91aabad36..8f6741e564 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletLaunchRegistry.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletLaunchRegistry.kt @@ -76,7 +76,7 @@ object NappletLaunchRegistry { accountPubKey: HexKey, ): String { val token = ByteArray(32).also(secureRandom::nextBytes).toHexKey() - sessions[token] = Session(identity, declared, accountPubKey) + sessions[token] = Session(identity.copy(instanceId = token), declared, accountPubKey) return token } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletLauncher.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletLauncher.kt index 2c98ffb8fe..c923b799d7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletLauncher.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletLauncher.kt @@ -25,16 +25,20 @@ import android.content.Intent import android.content.res.Configuration import android.os.Bundle import com.vitorpamplona.amethyst.Amethyst +import com.vitorpamplona.amethyst.commons.napplet.NappletArtifactPolicy import com.vitorpamplona.amethyst.commons.napplet.NappletIdentity import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.ThemeType import com.vitorpamplona.amethyst.napplethost.HostProfile import com.vitorpamplona.amethyst.napplethost.NappletHostActivity import com.vitorpamplona.amethyst.napplethost.NappletHostContract +import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.crypto.verify import com.vitorpamplona.quartz.nip5aStaticWebsites.tags.PathTag import com.vitorpamplona.quartz.nip5dNapplets.NappletManifest import com.vitorpamplona.quartz.nipB7Blossom.BlossomServersEvent +import com.vitorpamplona.quartz.utils.Log /** * Opens a napplet/nsite in the sandboxed [NappletHostActivity] (the `:napplet` process). Only @@ -49,20 +53,18 @@ object NappletLauncher { manifest: NappletManifest, authorPubKey: HexKey, identifier: String, - ) = launch( - context = context, - paths = manifest.paths(), - servers = manifest.servers(), - authorPubKey = authorPubKey, - identifier = identifier, - aggregateHash = manifest.declaredAggregateHash() ?: manifest.computeAggregateHash(), - title = manifest.title() ?: identifier.ifBlank { "Napplet" }, - requires = manifest.requires(), - ) + ) { + val event = manifest as? Event + if (event?.verify() != true || event.pubKey != authorPubKey) { + Log.w(TAG) { "Refusing NIP-5D manifest that failed signature/author verification" } + return + } + buildLaunchParams(context, manifest, authorPubKey, identifier)?.let { openHost(context, it) } + } /** - * Opens any NIP-5A static site (nsite or napplet). [requires] is empty for a plain nsite — - * the broker then refuses every capability, so the site renders as inert static content. + * Opens a NIP-5A website from its already-resolved path data. NIP-5D napplets use the verified + * manifest overload so raw callers cannot bypass signature/author validation. */ fun launch( context: Context, @@ -73,12 +75,26 @@ object NappletLauncher { aggregateHash: HexKey?, title: String, requires: List, - // nSites open as [HostProfile.WEBSITE]: a NIP-07 window.nostr provider + normal network. The - // broker then grants the IDENTITY + RELAY capabilities NIP-07 needs (consent-gated), regardless - // of the (empty) manifest `requires`. Napplets keep the default locked [HostProfile.NAPPLET]. - profile: HostProfile = HostProfile.NAPPLET, + // Raw path data is accepted only for the legacy NIP-5A website profile. NIP-5D callers must + // use the signature-checking manifest overload above. + profile: HostProfile, + ) { + if (profile != HostProfile.WEBSITE) { + Log.w(TAG) { "Refusing raw NIP-5D launch without a verified manifest" } + return + } + val params = + runCatching { buildLaunchParams(context, paths, servers, authorPubKey, identifier, aggregateHash, title, requires, profile) } + .onFailure { Log.w(TAG, "Refusing invalid ${profile.name.lowercase()} launch", it) } + .getOrNull() + ?: return + openHost(context, params) + } + + private fun openHost( + context: Context, + params: Bundle, ) { - val params = buildLaunchParams(context, paths, servers, authorPubKey, identifier, aggregateHash, title, requires, profile) val intent = Intent(context, NappletHostActivity::class.java).apply { putExtras(params) @@ -105,6 +121,29 @@ object NappletLauncher { requires: List, profile: HostProfile, ): Bundle { + require(profile == HostProfile.WEBSITE) { "NIP-5D launch parameters require a verified manifest." } + return buildLaunchParamsTrusted(context, paths, servers, authorPubKey, identifier, aggregateHash, title, requires, profile) + } + + private fun buildLaunchParamsTrusted( + context: Context, + paths: List, + servers: List, + authorPubKey: HexKey, + identifier: String, + aggregateHash: HexKey?, + title: String, + requires: List, + profile: HostProfile, + ): Bundle { + val effectiveAggregateHash = + if (profile == HostProfile.NAPPLET) { + requireNotNull(NappletArtifactPolicy.verifiedAggregateHash(paths, aggregateHash)) { + "NIP-5D requires one self-contained /index.html with a valid blob hash and matching aggregate." + } + } else { + aggregateHash + } val proxyPort = Amethyst.instance.torManager.activePortOrNull.value ?: -1 // Augment the manifest's servers with the author's published Blossom list (kind:10063), if @@ -118,7 +157,7 @@ object NappletLauncher { // Mint the launch token in the (trusted) main process: the broker resolves the sandbox's // requests back to THIS identity + declared set, regardless of anything the sandbox sends. - val identity = NappletIdentity(authorPubKey = authorPubKey, identifier = identifier, aggregateHash = aggregateHash) + val identity = NappletIdentity(authorPubKey = authorPubKey, identifier = identifier, aggregateHash = effectiveAggregateHash) val declared = profile.declaredCapabilities(requires) // Bound to the account launching it, so the surface keeps signing as that account even if the // user switches while it is open (an embedded surface is rebuilt on a switch and re-mints). @@ -156,7 +195,7 @@ object NappletLauncher { putStringArrayList(NappletHostContract.EXTRA_SERVERS, ArrayList(allServers)) putString(NappletHostContract.EXTRA_AUTHOR, authorPubKey) putString(NappletHostContract.EXTRA_IDENTIFIER, identifier) - putString(NappletHostContract.EXTRA_AGGREGATE_HASH, aggregateHash) + putString(NappletHostContract.EXTRA_AGGREGATE_HASH, effectiveAggregateHash) putString(NappletHostContract.EXTRA_TITLE, title) putStringArrayList(NappletHostContract.EXTRA_REQUIRES, ArrayList(requires)) putStringArrayList(NappletHostContract.EXTRA_CAP_LABELS, ArrayList(capLabels)) @@ -170,4 +209,34 @@ object NappletLauncher { putString(NappletHostContract.EXTRA_WEBVIEW_PROFILE, NappletWebViewProfiles.current()) } } + + /** Signature-checking entry point for embedded NIP-5D surfaces. */ + fun buildLaunchParams( + context: Context, + manifest: NappletManifest, + authorPubKey: HexKey, + identifier: String, + ): Bundle? { + val event = manifest as? Event + if (event?.verify() != true || event.pubKey != authorPubKey) { + Log.w(TAG) { "Refusing embedded NIP-5D manifest that failed signature/author verification" } + return null + } + return runCatching { + buildLaunchParamsTrusted( + context = context, + paths = manifest.paths(), + servers = manifest.servers(), + authorPubKey = authorPubKey, + identifier = identifier, + aggregateHash = manifest.declaredAggregateHash() ?: manifest.computeAggregateHash(), + title = manifest.title() ?: identifier.ifBlank { "Napplet" }, + requires = manifest.requires(), + profile = HostProfile.NAPPLET, + ) + }.onFailure { Log.w(TAG, "Refusing invalid embedded NIP-5D launch", it) } + .getOrNull() + } + + private const val TAG = "NappletLauncher" } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletLiveSubscriptions.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletLiveSubscriptions.kt index 1e33c16323..89e56f0b3f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletLiveSubscriptions.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletLiveSubscriptions.kt @@ -27,6 +27,10 @@ import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.launch import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.atomic.AtomicBoolean import java.util.concurrent.atomic.AtomicInteger @@ -44,7 +48,9 @@ import java.util.concurrent.atomic.AtomicInteger * signatures still came from the old one. [open] is reached only after the broker authorized the * subscription (RELAY consent). */ -class NappletLiveSubscriptions { +class NappletLiveSubscriptions( + private val scope: CoroutineScope, +) { private val liveSubs = ConcurrentHashMap() private val liveSeq = AtomicInteger(0) @@ -53,6 +59,20 @@ class NappletLiveSubscriptions { val client: INostrClient, ) { val eoseSent = AtomicBoolean(false) + val deliveries = Channel(Channel.UNLIMITED) + var deliveryJob: Job? = null + } + + private sealed interface Delivery { + data class RelayEvent( + val event: Event, + ) : Delivery + + data object Eose : Delivery + + data class Closed( + val reason: String, + ) : Delivery } /** @@ -77,6 +97,20 @@ class NappletLiveSubscriptions { // can't collide with the subscription it's replacing. val sub = LiveSub("napplet-$nappletSubId-${liveSeq.incrementAndGet()}", account.client) liveSubs[nappletSubId] = sub + sub.deliveryJob = + scope.launch { + for (delivery in sub.deliveries) { + if (liveSubs[nappletSubId] !== sub) break + when (delivery) { + is Delivery.RelayEvent -> + NappletRelayCleartext.forDelivery(delivery.event, account.signer)?.let { + push(NappletProtocolJson.encodeRelayEvent(nappletSubId, it)) + } + Delivery.Eose -> push(NappletProtocolJson.encodeRelayEose(nappletSubId)) + is Delivery.Closed -> push(NappletProtocolJson.encodeRelayClosed(nappletSubId, delivery.reason)) + } + } + } val listener = object : SubscriptionListener { @@ -85,7 +119,9 @@ class NappletLiveSubscriptions { isLive: Boolean, relay: NormalizedRelayUrl, forFilters: List?, - ) = push(NappletProtocolJson.encodeRelayEvent(nappletSubId, event)) + ) { + sub.deliveries.trySend(Delivery.RelayEvent(event)) + } // A subscription fans out to several relays; collapse their EOSEs into the single // relay.eose the SDK expects (fired when the first relay finishes its stored events). @@ -93,14 +129,16 @@ class NappletLiveSubscriptions { relay: NormalizedRelayUrl, forFilters: List?, ) { - if (sub.eoseSent.compareAndSet(false, true)) push(NappletProtocolJson.encodeRelayEose(nappletSubId)) + if (sub.eoseSent.compareAndSet(false, true)) sub.deliveries.trySend(Delivery.Eose) } override fun onClosed( message: String, relay: NormalizedRelayUrl, forFilters: List?, - ) = push(NappletProtocolJson.encodeRelayClosed(nappletSubId, message)) + ) { + sub.deliveries.trySend(Delivery.Closed(message)) + } } runCatching { sub.client.subscribe(sub.clientSubId, relays.associateWith { filters }, listener) } @@ -109,12 +147,18 @@ class NappletLiveSubscriptions { /** Stops the live subscription for [nappletSubId], unsubscribing from the client that opened it. */ fun close(nappletSubId: String) { val sub = liveSubs.remove(nappletSubId) ?: return + sub.deliveries.close() + sub.deliveryJob?.cancel() runCatching { sub.client.unsubscribe(sub.clientSubId) } } /** Tears down every open subscription (service teardown). */ fun closeAll() { - liveSubs.values.forEach { sub -> runCatching { sub.client.unsubscribe(sub.clientSubId) } } + liveSubs.values.forEach { sub -> + sub.deliveries.close() + sub.deliveryJob?.cancel() + runCatching { sub.client.unsubscribe(sub.clientSubId) } + } liveSubs.clear() } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletRelayCleartext.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletRelayCleartext.kt new file mode 100644 index 0000000000..3b55dad5cd --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletRelayCleartext.kt @@ -0,0 +1,75 @@ +/* + * 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.napplet + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip04Dm.crypto.EncryptedInfo +import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent +import com.vitorpamplona.quartz.nip44Encryption.Nip44v2 + +/** NAP-RELAY read boundary: encrypted event content is decrypted or withheld, never exposed. */ +internal object NappletRelayCleartext { + suspend fun forDelivery( + event: Event, + signer: NostrSigner, + ): Event? = forDelivery(event, signer.pubKey, signer::decrypt) + + internal suspend fun forDelivery( + event: Event, + userPubKey: HexKey, + decrypt: suspend (String, HexKey) -> String, + ): Event? { + if (!isEncrypted(event)) return event + + val peer = + when { + event.pubKey == userPubKey -> event.recipientPubKey() + event.isAddressedTo(userPubKey) -> event.pubKey + else -> null + } ?: return null + val cleartext = runCatching { decrypt(event.content, peer) }.getOrNull() ?: return null + + // NAP-RELAY defines a decrypted read projection. Retain the relay event's identity and + // signature fields so callers can still correlate it, while making clear that this object + // must never be republished as a signed event after its content projection has changed. + return Event(event.id, event.pubKey, event.createdAt, event.kind, event.tags, cleartext, event.sig) + } + + internal fun isEncrypted(event: Event): Boolean = + event is PrivateDmEvent || + EncryptedInfo.isNIP04(event.content) || + isNip44V2(event.content) + + private fun isNip44V2(content: String): Boolean = + content.length >= MIN_NIP44_V2_LENGTH && + runCatching { Nip44v2.EncryptedInfo.decodePayload(content) }.isSuccess + + private fun Event.recipientPubKey(): HexKey? = + tags.firstNotNullOfOrNull { tag -> + tag.getOrNull(1)?.takeIf { tag.getOrNull(0) == "p" } + } + + private fun Event.isAddressedTo(pubKey: HexKey): Boolean = tags.any { tag -> tag.getOrNull(0) == "p" && tag.getOrNull(1) == pubKey } + + private const val MIN_NIP44_V2_LENGTH = 132 +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/gateways/AccountNappletGateways.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/gateways/AccountNappletGateways.kt index 139a935d5f..3222e810c8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/gateways/AccountNappletGateways.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/gateways/AccountNappletGateways.kt @@ -50,6 +50,7 @@ import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.napplet.NappletConsentCoordinator import com.vitorpamplona.amethyst.napplet.NappletConsentSummary import com.vitorpamplona.amethyst.napplet.NappletNotificationStore +import com.vitorpamplona.amethyst.napplet.NappletRelayCleartext import com.vitorpamplona.amethyst.napplet.buildConnectInfo import com.vitorpamplona.amethyst.napplet.buildSignerConsentInfo import com.vitorpamplona.amethyst.service.uploads.blossom.BlossomUploader @@ -265,7 +266,8 @@ class AccountNappletGateways( .distinctBy { it.id } .sortedByDescending { it.createdAt } val limit = filters.mapNotNull { it.limit }.maxOrNull() - return limit?.let { merged.take(it) } ?: merged + val limited = limit?.let { merged.take(it) } ?: merged + return limited.mapNotNull { NappletRelayCleartext.forDelivery(it, account.signer) } } /** diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/gateways/NappletResourceFetcher.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/gateways/NappletResourceFetcher.kt index c64fb12eca..24bf6e655c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/gateways/NappletResourceFetcher.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/gateways/NappletResourceFetcher.kt @@ -22,6 +22,7 @@ package com.vitorpamplona.amethyst.napplet.gateways import android.util.Base64 import com.vitorpamplona.amethyst.commons.napplet.NappletResource +import com.vitorpamplona.amethyst.commons.napplet.NappletResourceResult import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.napplet.NappletNetworkRegistry import com.vitorpamplona.quartz.nip01Core.core.Address @@ -38,10 +39,27 @@ import com.vitorpamplona.quartz.nip19Bech32.entities.NPub import com.vitorpamplona.quartz.nip5aStaticWebsites.resolver.StaticSiteResolver import com.vitorpamplona.quartz.nip5aStaticWebsites.resolver.sniffContentType import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.coroutines.withContext +import kotlinx.serialization.json.Json +import okhttp3.Authenticator +import okhttp3.Call +import okhttp3.Callback +import okhttp3.CookieJar +import okhttp3.Dns +import okhttp3.HttpUrl +import okhttp3.HttpUrl.Companion.toHttpUrlOrNull import okhttp3.OkHttpClient import okhttp3.Request +import okhttp3.Response +import java.io.ByteArrayOutputStream +import java.io.IOException +import java.io.InterruptedIOException +import java.net.InetAddress import java.net.URLDecoder +import java.nio.ByteBuffer +import java.nio.charset.CodingErrorAction +import java.util.concurrent.TimeUnit /** * Fetches a resource URL on an applet's behalf — the applet has no direct network @@ -63,41 +81,100 @@ class NappletResourceFetcher( private val account: Account, private val httpClient: (useProxy: Boolean) -> OkHttpClient, ) { - /** Fetches an https/data/blossom resource for the applet at [coordinate], or null if unsupported/unavailable. */ + /** Fetches an https/data/blossom/nostr resource and preserves the NAP-RESOURCE error category. */ suspend fun fetch( url: String, coordinate: String, - ): NappletResource? = + ): NappletResourceResult = withContext(Dispatchers.IO) { - // Route like the applet's own page: Tor when its network mode is Tor, clearnet otherwise. - NappletNetworkRegistry.awaitReady() - val client = httpClient(NappletNetworkRegistry.useTor(coordinate)) when { url.startsWith("data:") -> decodeDataUrl(url) - url.startsWith("https://") -> { - runCatching { - client - .newCall( - Request - .Builder() - .url(url) - .get() - .build(), - ).execute() - .use { r -> - if (!r.isSuccessful) return@withContext null - val body = r.body.bytes() - val type = r.header("Content-Type") ?: "application/octet-stream" - NappletResource(body, type) - } - }.getOrNull() + url.startsWith("nostr:") -> + resolveNostr(url)?.let(::success) ?: failure(ERROR_NOT_FOUND, "Nostr resource not found.") + url.startsWith("https://") || url.startsWith("blossom:") -> { + // Route like the applet's own page: locked napplets stay on Tor. The derived + // client removes ambient cookies/auth and validates DNS before every hop. + NappletNetworkRegistry.awaitReady() + val client = hardenedClient(httpClient(NappletNetworkRegistry.useTor(coordinate))) + if (url.startsWith("https://")) fetchHttps(url, client) else fetchBlossom(url, client) } - url.startsWith("blossom:") -> fetchBlossom(url, client) - url.startsWith("nostr:") -> resolveNostr(url) - else -> null + else -> failure(ERROR_UNSUPPORTED_SCHEME, "Unsupported resource URL scheme.") } } + private fun hardenedClient(baseClient: OkHttpClient): OkHttpClient = + baseClient + .newBuilder() + .followRedirects(false) + .followSslRedirects(false) + .cache(null) + .cookieJar(CookieJar.NO_COOKIES) + .authenticator(Authenticator.NONE) + .proxyAuthenticator(Authenticator.NONE) + .callTimeout(FETCH_TIMEOUT_SECONDS, TimeUnit.SECONDS) + .dns( + Dns { hostname -> + baseClient.dns.lookup(hostname).also { addresses -> + if (addresses.isEmpty() || !addresses.all(::isPublicAddress)) { + throw BlockedResourceException("Resolved address is not public.") + } + } + }, + ).addNetworkInterceptor { chain -> + chain.proceed( + chain + .request() + .newBuilder() + .removeHeader("Authorization") + .removeHeader("Cookie") + .removeHeader("Proxy-Authorization") + .build(), + ) + }.build() + + private suspend fun fetchHttps( + url: String, + client: OkHttpClient, + ): NappletResourceResult { + var current = safeHttpsUrl(url) ?: return failure(ERROR_BLOCKED, "Only credential-free HTTPS URLs are allowed.") + repeat(MAX_REDIRECTS + 1) { hop -> + try { + client + .newCall( + Request + .Builder() + .url(current) + .get() + .build(), + ).await() + .use { response -> + if (response.isRedirect) { + if (hop >= MAX_REDIRECTS) return failure(ERROR_BLOCKED, "Redirect limit exceeded.") + val location = response.header("Location") ?: return failure(ERROR_NETWORK, "Redirect has no location.") + current = safeHttpsUrl(current.resolve(location)) ?: return failure(ERROR_BLOCKED, "Redirect left credential-free HTTPS.") + return@repeat + } + if (response.code == 404) return failure(ERROR_NOT_FOUND) + if (!response.isSuccessful) return failure(ERROR_NETWORK, "Upstream returned HTTP ${response.code}.") + if (response.body.contentLength() > MAX_RESOURCE_BYTES) return failure(ERROR_TOO_LARGE) + val body = readBounded(response.body.byteStream()) ?: return failure(ERROR_TOO_LARGE) + return classify(body) + } + } catch (e: BlockedResourceException) { + return failure(ERROR_BLOCKED, e.message) + } catch (_: InterruptedIOException) { + return failure(ERROR_TIMEOUT) + } catch (_: Exception) { + return failure(ERROR_NETWORK) + } + } + return failure(ERROR_BLOCKED, "Redirect limit exceeded.") + } + + private fun safeHttpsUrl(url: String): HttpUrl? = url.toHttpUrlOrNull()?.takeIf { isSafeHttpsResourceUrl(url) } + + private fun safeHttpsUrl(url: HttpUrl?): HttpUrl? = url?.takeIf { it.scheme == "https" && it.username.isEmpty() && it.password.isEmpty() } + /** * Resolves a `nostr:` URI (NIP-19) to the referenced event and returns its JSON. An `nembed` * carries the event inline; `note`/`nevent`/`naddr` resolve from the local cache, falling back to @@ -155,65 +232,205 @@ class NappletResourceFetcher( * wrong server can never substitute the blob. Returns null for a malformed hash or if no server * serves it. */ - private fun fetchBlossom( + private suspend fun fetchBlossom( url: String, client: OkHttpClient, - ): NappletResource? { - val hash = - url - .removePrefix("blossom://") - .removePrefix("blossom:") - .substringBefore('/') - .substringBefore('?') - .trim() - .lowercase() - if (!hash.matches(Regex("^[0-9a-f]{64}$"))) return null + ): NappletResourceResult { + if (!url.startsWith(BLOSSOM_SHA256_PREFIX)) return failure(ERROR_INVALID_REQUEST, "Malformed Blossom SHA-256 URL.") + val hash = url.removePrefix(BLOSSOM_SHA256_PREFIX).lowercase() + if (!hash.matches(SHA256)) return failure(ERROR_INVALID_REQUEST, "Malformed Blossom SHA-256 URL.") val servers = account.blossomServers .getBlossomServersList() ?.servers() .orEmpty() + var sawHashMismatch = false for (candidate in StaticSiteResolver.candidateUrls(servers, hash)) { - val bytes = - runCatching { - client - .newCall( - Request - .Builder() - .url(candidate) - .get() - .build(), - ).execute() - .use { r -> - if (r.isSuccessful) r.body.bytes() else null - } - }.getOrNull() ?: continue - if (StaticSiteResolver.verify(bytes, hash)) { - return NappletResource(bytes, sniffContentType(bytes) ?: "application/octet-stream") + when (val fetched = fetchHttps(candidate, client)) { + is NappletResourceResult.Success -> { + if (!StaticSiteResolver.verify(fetched.resource.bytes, hash)) { + sawHashMismatch = true + continue + } + return fetched + } + is NappletResourceResult.Failure -> if (fetched.error == ERROR_BLOCKED) return fetched } } - return null + if (sawHashMismatch) return failure(ERROR_DECODE_FAILED, "Blossom SHA-256 verification failed.") + return failure(ERROR_NOT_FOUND, "No Blossom server returned the verified blob.") } + private suspend fun Call.await(): Response = + suspendCancellableCoroutine { continuation -> + continuation.invokeOnCancellation { cancel() } + enqueue( + object : Callback { + override fun onFailure( + call: Call, + e: IOException, + ) { + if (continuation.isActive) continuation.resumeWith(Result.failure(e)) + } + + override fun onResponse( + call: Call, + response: Response, + ) { + if (continuation.isActive) { + continuation.resumeWith(Result.success(response)) + } else { + response.close() + } + } + }, + ) + } + /** Parses a `data:[][;base64],` URL into bytes + content type. */ - private fun decodeDataUrl(url: String): NappletResource? { + private fun decodeDataUrl(url: String): NappletResourceResult { val comma = url.indexOf(',') - if (comma < 0) return null + if (comma < 0) return failure(ERROR_INVALID_REQUEST, "Malformed data URL.") val meta = url.substring("data:".length, comma) val data = url.substring(comma + 1) + if (data.length > MAX_DATA_URL_CHARS) return failure(ERROR_TOO_LARGE) val isBase64 = meta.endsWith(";base64") - val contentType = meta.removeSuffix(";base64").ifEmpty { "text/plain" } + val declaredType = + meta + .removeSuffix(";base64") + .substringBefore(';') + .ifEmpty { "text/plain" } + .lowercase() val bytes = if (isBase64) { - runCatching { Base64.decode(data, Base64.DEFAULT) }.getOrNull() ?: return null + runCatching { Base64.decode(data, Base64.DEFAULT) }.getOrNull() + ?: return failure(ERROR_DECODE_FAILED, "Invalid base64 data URL.") } else { - URLDecoder.decode(data, "UTF-8").encodeToByteArray() + runCatching { URLDecoder.decode(data, "UTF-8").encodeToByteArray() }.getOrNull() + ?: return failure(ERROR_DECODE_FAILED, "Invalid escaped data URL.") } - return NappletResource(bytes, contentType) + if (bytes.size > MAX_RESOURCE_BYTES) return failure(ERROR_TOO_LARGE) + return classify(bytes, declaredType) + } + + private fun classify( + bytes: ByteArray, + declaredType: String? = null, + ): NappletResourceResult { + if (looksLikeSvg(bytes)) return failure(ERROR_BLOCKED, "Raw SVG is not delivered by this runtime.") + val sniffed = sniffContentType(bytes) + val type = + when { + sniffed in ALLOWED_SNIFFED_TYPES -> sniffed + declaredType == "application/json" && isJson(bytes) -> "application/json" + declaredType == "text/plain" && isPlainText(bytes) -> "text/plain" + else -> null + } ?: return failure(ERROR_DECODE_FAILED, "Resource MIME is not in the runtime allowlist.") + return success(NappletResource(bytes, type)) + } + + private fun looksLikeSvg(bytes: ByteArray): Boolean { + val prefix = bytes.copyOfRange(0, minOf(bytes.size, MIME_PREFIX_BYTES)).decodeToString().lowercase() + return prefix.contains(" + val output = ByteArrayOutputStream() + val buffer = ByteArray(8 * 1024) + var total = 0 + while (true) { + val read = source.read(buffer) + if (read < 0) break + total += read + if (total > MAX_RESOURCE_BYTES) return null + output.write(buffer, 0, read) + } + return output.toByteArray() + } } companion object { + internal fun isSafeHttpsResourceUrl(url: String): Boolean = url.toHttpUrlOrNull()?.let { it.scheme == "https" && it.username.isEmpty() && it.password.isEmpty() } == true + + internal fun isPublicAddress(address: InetAddress): Boolean { + if (address.isAnyLocalAddress || address.isLoopbackAddress || address.isLinkLocalAddress || address.isSiteLocalAddress || address.isMulticastAddress) { + return false + } + val bytes = address.address + if (bytes.size == 4) { + val first = bytes[0].toInt() and 0xff + val second = bytes[1].toInt() and 0xff + // Shared address space (100.64/10) and reserved/non-routed ranges Java does not classify. + if (first == 0 || first >= 224) return false + if (first == 100 && second in 64..127) return false + if (first == 192 && second == 0) return false + if (first == 198 && second in 18..19) return false + if (first == 198 && second == 51 && (bytes[2].toInt() and 0xff) == 100) return false + if (first == 203 && second == 0 && (bytes[2].toInt() and 0xff) == 113) return false + } else if (bytes.size == 16) { + val first = bytes[0].toInt() and 0xff + if (first and 0xfe == 0xfc) return false // fc00::/7 unique-local + if ( + first == 0x20 && + (bytes[1].toInt() and 0xff) == 0x01 && + (bytes[2].toInt() and 0xff) == 0x0d && + (bytes[3].toInt() and 0xff) == 0xb8 + ) { + return false // 2001:db8::/32 documentation range + } + } + return true + } + private const val NOSTR_FETCH_TIMEOUT_MS = 8_000L + private const val FETCH_TIMEOUT_SECONDS = 30L + private const val MAX_REDIRECTS = 5 + private const val MIME_PREFIX_BYTES = 8 * 1024 + private const val MAX_DATA_URL_CHARS = 24 * 1024 * 1024 + private const val BLOSSOM_SHA256_PREFIX = "blossom:sha256:" + const val MAX_RESOURCE_BYTES = 10 * 1024 * 1024 + private const val ERROR_INVALID_REQUEST = "invalid-request" + private const val ERROR_NOT_FOUND = "not-found" + private const val ERROR_BLOCKED = "blocked-by-policy" + private const val ERROR_TIMEOUT = "timeout" + private const val ERROR_TOO_LARGE = "too-large" + private const val ERROR_UNSUPPORTED_SCHEME = "unsupported-scheme" + private const val ERROR_DECODE_FAILED = "decode-failed" + private const val ERROR_NETWORK = "network-error" + private val SHA256 = Regex("^[0-9a-f]{64}$") + private val ALLOWED_SNIFFED_TYPES = + setOf( + "image/png", + "image/jpeg", + "image/gif", + "image/webp", + "image/bmp", + "audio/ogg", + "video/mp4", + ) } + + private class BlockedResourceException( + message: String, + ) : java.io.IOException(message) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationRelayService.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationRelayService.kt index 3411379e4c..d90c812658 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationRelayService.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationRelayService.kt @@ -373,6 +373,16 @@ class NotificationRelayService : Service() { if (fresh.isNotEmpty()) lastBreakdown = fresh val breakdown = fresh.ifEmpty { lastBreakdown }.takeIf { it.isNotEmpty() } + // Deliberately left ungrouped. This notification is ongoing and IMPORTANCE_LOW, so it + // sits in the shade's Silent section next to the low-importance content kinds + // (reactions, reposts) — and Android 16 sweeps everything ungrouped in a section into + // one aggregate bundle whose summary inherits FLAG_ONGOING_EVENT from any child that + // has it, making the whole bundle un-swipeable. Giving this one a group of its own + // would not help: a group with a summary but no children, or a child with no summary, + // is force-grouped just the same. What keeps content notifications out of that bundle + // is that they always post their own group summary (see NotificationUtils), which + // leaves this the only ungrouped silent notification we post — one is below the + // threshold, so no bundle is formed and nothing gets stapled to it. return NotificationCompat .Builder(this, CHANNEL_ID) .setContentTitle(getString(R.string.always_on_notif_title)) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationReplyReceiver.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationReplyReceiver.kt index ddb16c72cb..26b2930f02 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationReplyReceiver.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationReplyReceiver.kt @@ -30,6 +30,8 @@ import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.LocalPreferences import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.accountsCache.AccountCacheState +import com.vitorpamplona.amethyst.service.notifications.NotificationUtils.cancelAndPrune +import com.vitorpamplona.amethyst.service.notifications.NotificationUtils.cancelChildlessGroupSummaries import com.vitorpamplona.amethyst.ui.actions.NewMessageTagger import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle import com.vitorpamplona.quartz.nip01Core.tags.people.PTag @@ -54,13 +56,35 @@ class NotificationReplyReceiver : BroadcastReceiver() { intent: Intent, ) { val notificationId = intent.getIntExtra(NotificationUtils.KEY_NOTIFICATION_ID, 0) + + // Whatever the action, the user is done with this notification, so record it before + // doing anything else. An enrichment window may still be open on the event (up to + // 25s from the first post), and it re-posts the notification every time metadata + // lands — without this the notification the user just dealt with comes back, and the + // enricher keeps a relay subscription and a wakelock alive for it until the window + // elapses. Replies mark it after the send succeeds instead, so a failure leaves the + // notification to enrich and retry. + val eventId = intent.getStringExtra(NotificationUtils.KEY_EVENT_ID) + if (intent.action != NotificationUtils.REPLY_ACTION && + intent.action != NotificationUtils.PUBLIC_REPLY_ACTION && + intent.action != NotificationUtils.MARMOT_REPLY_ACTION + ) { + eventId?.let { NotificationUtils.markDismissed(it) } + } + val notificationManager = ContextCompat.getSystemService(context, NotificationManager::class.java) as NotificationManager when (intent.action) { NotificationUtils.MARK_READ_ACTION -> { - notificationManager.cancel(notificationId) + notificationManager.cancelAndPrune(notificationId) + } + + // The user swiped the notification away. It is already gone; all that is left + // is to take its group summary with it when it was the last child. + NotificationUtils.DISMISS_ACTION -> { + notificationManager.cancelChildlessGroupSummaries(alreadyGone = notificationId) } NotificationUtils.REPLY_ACTION -> { @@ -78,7 +102,7 @@ class NotificationReplyReceiver : BroadcastReceiver() { if (members.isEmpty()) return - runOnRelay(notificationManager, notificationId) { + runOnRelay(notificationManager, notificationId, eventId) { sendReply(accountNpub, members, replyText) } } @@ -95,7 +119,7 @@ class NotificationReplyReceiver : BroadcastReceiver() { val accountNpub = intent.getStringExtra(NotificationUtils.KEY_ACCOUNT_NPUB) ?: return val targetEventId = intent.getStringExtra(NotificationUtils.KEY_TARGET_EVENT_ID) ?: return - runOnRelay(notificationManager, notificationId) { + runOnRelay(notificationManager, notificationId, eventId) { sendPublicReply(accountNpub, targetEventId, replyText) } } @@ -114,7 +138,7 @@ class NotificationReplyReceiver : BroadcastReceiver() { val replyToInnerId = intent.getStringExtra(NotificationUtils.KEY_MARMOT_REPLY_TO_INNER_ID) val replyToInnerAuthor = intent.getStringExtra(NotificationUtils.KEY_MARMOT_REPLY_TO_INNER_AUTHOR) - runOnRelay(notificationManager, notificationId) { + runOnRelay(notificationManager, notificationId, eventId) { sendMarmotReply(accountNpub, nostrGroupId, replyToInnerId, replyToInnerAuthor, replyText) } } @@ -124,6 +148,7 @@ class NotificationReplyReceiver : BroadcastReceiver() { private fun runOnRelay( notificationManager: NotificationManager, notificationId: Int, + eventId: String?, block: suspend () -> Unit, ) { val pendingResult = goAsync() @@ -138,7 +163,8 @@ class NotificationReplyReceiver : BroadcastReceiver() { try { block() - notificationManager.cancel(notificationId) + eventId?.let { NotificationUtils.markDismissed(it) } + notificationManager.cancelAndPrune(notificationId) } catch (e: Exception) { if (e is CancellationException) throw e Log.e("NotificationReply") { "Failed to send reply: ${e.message}" } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationUtils.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationUtils.kt index a1879a70f4..6d4791386c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationUtils.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationUtils.kt @@ -70,8 +70,16 @@ object NotificationUtils { const val PUBLIC_REPLY_ACTION = "com.vitorpamplona.amethyst.PUBLIC_REPLY_ACTION" const val MARMOT_REPLY_ACTION = "com.vitorpamplona.amethyst.MARMOT_REPLY_ACTION" const val MARK_READ_ACTION = "com.vitorpamplona.amethyst.MARK_READ_ACTION" + const val DISMISS_ACTION = "com.vitorpamplona.amethyst.DISMISS_ACTION" const val KEY_REPLY_TEXT = "key_reply_text" const val KEY_NOTIFICATION_ID = "key_notification_id" + + /** + * Hex id of the event this notification was posted for, carried on every action + * and on the delete intent so the receiver can mark it dismissed. Distinct from + * [KEY_TARGET_EVENT_ID], which is the note an inline reply is addressed to. + */ + const val KEY_EVENT_ID = "key_event_id" const val KEY_ACCOUNT_NPUB = "key_account_npub" const val KEY_CHATROOM_MEMBERS = "key_chatroom_members" const val KEY_TARGET_EVENT_ID = "key_target_event_id" @@ -82,16 +90,27 @@ object NotificationUtils { const val REPLY_GROUP_KEY_PREFIX = "com.vitorpamplona.amethyst.REPLY_NOTIFICATION" private const val REPLY_SUMMARY_ID_BASE = 0x50000 - // Event ids the user has just read/dismissed in-app. The enrichment path - // re-posts a notification as metadata arrives; without this guard a - // notification the user already dismissed would be resurrected seconds later - // when its author's kind:0 lands. Keyed by the event id string (not the - // hashCode) so distinct events can't collide. Entries self-expire after a - // window comfortably longer than the 25s enrichment window. + /** + * Every group key this object posts under starts with this. Used to tell our own + * summaries apart from the ones the system creates when it force-groups us (those + * live under `userId|pkg|g:Aggregate_…`), so the cleanup below never fights the + * platform over a bundle it owns. + */ + private const val OWN_GROUP_PREFIX = "com.vitorpamplona.amethyst." + + // Event ids the user is done with. The enrichment path re-posts a notification + // as metadata arrives; without this guard a notification the user already got + // rid of would be resurrected seconds later when its author's kind:0 lands, and + // the enricher would go on holding a relay window and a wakelock open for it. + // Every way a user can be done with a notification has to record here — reading + // the event in-app, swiping the notification away, "mark as read", and replying + // inline — or that path leaks the resurrection. Keyed by the event id string + // (not the hashCode) so distinct events can't collide. Entries self-expire after + // a window comfortably longer than the 25s enrichment window. private const val DISMISS_GUARD_MS = 90_000L private val recentlyDismissed = ConcurrentHashMap() - private fun markDismissed(eventId: String) { + fun markDismissed(eventId: String) { val now = SystemClock.elapsedRealtime() recentlyDismissed[eventId] = now + DISMISS_GUARD_MS if (recentlyDismissed.size > 256) { @@ -218,6 +237,7 @@ object NotificationUtils { .setPriority(category.priority()) .setCategory(NotificationCompat.CATEGORY_SOCIAL) .setGroup(groupKey) + .setDeleteIntent(dismissIntent(applicationContext, notId, id)) .setAutoCancel(true) .setOnlyAlertOnce(true) .setWhen(time * 1000) @@ -236,11 +256,11 @@ object NotificationUtils { } if (inlineReply != null) { - builder.addAction(publicReplyAction(applicationContext, notId, inlineReply)) + builder.addAction(publicReplyAction(applicationContext, notId, id, inlineReply)) } notify(notId, builder.build()) - sendGroupSummary(category, groupKey, summaryId, applicationContext) + sendGroupSummary(category, groupKey, summaryId, time, applicationContext) } // --------------------------------------------------------------------- @@ -335,20 +355,21 @@ object NotificationUtils { .setPriority(category.priority()) .setCategory(NotificationCompat.CATEGORY_MESSAGE) .setGroup(groupKey) + .setDeleteIntent(dismissIntent(applicationContext, notId, id)) .setAutoCancel(true) .setOnlyAlertOnce(true) .setWhen(time * 1000) when (replyAction) { - is ReplyAction.Dm -> builder.addAction(dmReplyAction(applicationContext, notId, replyAction)) - is ReplyAction.Marmot -> builder.addAction(marmotReplyAction(applicationContext, notId, replyAction)) - null -> publicInlineReply?.let { builder.addAction(publicReplyAction(applicationContext, notId, it)) } + is ReplyAction.Dm -> builder.addAction(dmReplyAction(applicationContext, notId, id, replyAction)) + is ReplyAction.Marmot -> builder.addAction(marmotReplyAction(applicationContext, notId, id, replyAction)) + null -> publicInlineReply?.let { builder.addAction(publicReplyAction(applicationContext, notId, id, it)) } } - if (addMarkRead) builder.addAction(markReadAction(applicationContext, notId)) + if (addMarkRead) builder.addAction(markReadAction(applicationContext, notId, id)) notify(notId, builder.build()) - sendGroupSummary(category, groupKey, summaryId, applicationContext) + sendGroupSummary(category, groupKey, summaryId, time, applicationContext) } // --------------------------------------------------------------------- @@ -370,6 +391,38 @@ object NotificationUtils { ) } + /** + * Fires when the user swipes this notification away (or hits "Clear all"), so the + * group summary can follow its last child out. + * + * We can't rely on the shade to take the summary with it: SystemUI hides a group + * with a single child and renders that child at the top level + * (`ShadeListBuilder.MIN_CHILDREN_FOR_GROUP`), and once promoted the child no + * longer counts as "the only child in its group", so dismissing it leaves our + * summary behind. A childless summary is not harmless — SystemUI promotes it into + * the shade on its own, and the system force-groups it + * (`GroupHelper.isGroupSummaryWithoutChildren`) into the same aggregate bundle we + * post summaries to stay out of. + */ + private fun dismissIntent( + applicationContext: Context, + notId: Int, + eventId: String, + ): PendingIntent { + val intent = + Intent(applicationContext, NotificationReplyReceiver::class.java).apply { + action = DISMISS_ACTION + putExtra(KEY_NOTIFICATION_ID, notId) + putExtra(KEY_EVENT_ID, eventId) + } + return PendingIntent.getBroadcast( + applicationContext, + notId + 2, + intent, + PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT, + ) + } + private fun replyRemoteInput(applicationContext: Context): RemoteInput = RemoteInput .Builder(KEY_REPLY_TEXT) @@ -399,12 +452,14 @@ object NotificationUtils { private fun dmReplyAction( applicationContext: Context, notId: Int, + eventId: String, action: ReplyAction.Dm, ): NotificationCompat.Action { val intent = Intent(applicationContext, NotificationReplyReceiver::class.java).apply { this.action = REPLY_ACTION putExtra(KEY_NOTIFICATION_ID, notId) + putExtra(KEY_EVENT_ID, eventId) putExtra(KEY_ACCOUNT_NPUB, action.accountNpub) putExtra(KEY_CHATROOM_MEMBERS, action.chatroomMembers) } @@ -414,12 +469,14 @@ object NotificationUtils { private fun marmotReplyAction( applicationContext: Context, notId: Int, + eventId: String, action: ReplyAction.Marmot, ): NotificationCompat.Action { val intent = Intent(applicationContext, NotificationReplyReceiver::class.java).apply { this.action = MARMOT_REPLY_ACTION putExtra(KEY_NOTIFICATION_ID, notId) + putExtra(KEY_EVENT_ID, eventId) putExtra(KEY_ACCOUNT_NPUB, action.accountNpub) putExtra(KEY_MARMOT_GROUP_ID, action.nostrGroupId) action.replyToInnerEventId?.let { putExtra(KEY_MARMOT_REPLY_TO_INNER_ID, it) } @@ -431,12 +488,14 @@ object NotificationUtils { private fun publicReplyAction( applicationContext: Context, notId: Int, + eventId: String, target: InlineReplyTarget, ): NotificationCompat.Action { val intent = Intent(applicationContext, NotificationReplyReceiver::class.java).apply { action = PUBLIC_REPLY_ACTION putExtra(KEY_NOTIFICATION_ID, notId) + putExtra(KEY_EVENT_ID, eventId) putExtra(KEY_ACCOUNT_NPUB, target.accountNpub) putExtra(KEY_TARGET_EVENT_ID, target.targetEventId) } @@ -446,11 +505,13 @@ object NotificationUtils { private fun markReadAction( applicationContext: Context, notId: Int, + eventId: String, ): NotificationCompat.Action { val markReadIntent = Intent(applicationContext, NotificationReplyReceiver::class.java).apply { action = MARK_READ_ACTION putExtra(KEY_NOTIFICATION_ID, notId) + putExtra(KEY_EVENT_ID, eventId) } val markReadPendingIntent = PendingIntent.getBroadcast( @@ -549,16 +610,33 @@ object NotificationUtils { // Group summaries, dedup, dismissal // --------------------------------------------------------------------- + /** + * Posts (or refreshes) our own summary for [groupKey]. + * + * The summary goes up with the **first** child, not once a second one shows up. + * Android 16 counts a group child whose summary is missing as ungrouped + * (`GroupHelper.isGroupChildWithoutSummary`) and force-groups it into the + * package's per-section aggregate bundle, next to every other ungrouped + * notification in the same shade section. The bar is low: `config_autoGroupAtCount` + * is 2, so a single summary-less child plus one other ungrouped notification is a + * bundle. The always-on relay service is exactly that other notification — ongoing + * and IMPORTANCE_LOW, it shares the Silent section with our two IMPORTANCE_LOW + * kinds (reactions and reposts), so one lone repost would end up bundled with it. + * The bundle then refuses to swipe away, because the system's aggregate summary + * inherits FLAG_ONGOING_EVENT from any child carrying it — and the service + * notification always does. + * + * Providing the summary from the start keeps the group ours and the system leaves + * it alone. It costs nothing visually: the shade hides any group with fewer than + * two children and shows the child on its own. + */ private fun NotificationManager.sendGroupSummary( category: NotificationCategory, groupKey: String, summaryId: Int, + time: Long, applicationContext: Context, ) { - val activeCount = activeNotifications.count { it.notification.group == groupKey && it.id != summaryId } - - if (activeCount < 2) return - val summaryBuilder = NotificationCompat .Builder(applicationContext, category.channelId(applicationContext)) @@ -566,8 +644,16 @@ object NotificationUtils { .setColor(category.color) .setGroup(groupKey) .setGroupSummary(true) + // The children do the alerting. Without this the summary would buzz on + // its own the moment it starts going up alongside the first child. + .setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_CHILDREN) .setAutoCancel(true) .setOnlyAlertOnce(true) + // Pinned to the child's event time rather than left to default to "now". + // The summary is re-posted on every one of the enrichment path's re-renders, + // and a fresh timestamp each time would keep re-sorting the group in the + // shade while the user is looking at it. + .setWhen(time * 1000) .setStyle( NotificationCompat .InboxStyle() @@ -604,17 +690,53 @@ object NotificationUtils { // items), so bail out before touching anything when nothing is posted for it. if (activeNotifications.none { it.id == notId }) return - cancel(notId) - cancelChildlessGroupSummaries() + cancelAndPrune(notId) } - private fun NotificationManager.cancelChildlessGroupSummaries() { + /** + * Cancels [notId] and drops the group summary it leaves behind, if it was the last + * child. Use this instead of a bare [NotificationManager.cancel] for anything we + * posted through [postStandard] / [postConversation] — every one of those is a + * group child with a summary above it. + */ + fun NotificationManager.cancelAndPrune(notId: Int) { + cancel(notId) + cancelChildlessGroupSummaries(alreadyGone = notId) + } + + /** + * Drops our summaries that no longer have any children. + * + * [alreadyGone] is the id of a notification cancelled moments ago: both + * [NotificationManager.cancel] and [NotificationManager.notify] are asynchronous, + * so [NotificationManager.activeNotifications] can still be listing it and would + * otherwise keep its summary alive forever. + * + * Only summaries under [OWN_GROUP_PREFIX] are touched. The system's own aggregate + * summaries also carry FLAG_GROUP_SUMMARY and show up in this list; cancelling one + * only makes the platform rebuild it. + */ + fun NotificationManager.cancelChildlessGroupSummaries(alreadyGone: Int? = null) { val active: Array = activeNotifications + + // Collect the groups that still have a child in one pass, then cancel the + // summaries not in that set. Every child now ships with a summary, so this list + // is about twice as long as it used to be and the pairwise scan it replaces grew + // four-fold. Membership is decided by the summary flag rather than by comparing + // ids, which is also what makes it correct when a child's id happens to equal the + // summary's. + val groupsWithChildren = HashSet(active.size) + for (child in active) { + if (child.notification.flags and Notification.FLAG_GROUP_SUMMARY != 0) continue + if (child.id == alreadyGone) continue + child.notification.group?.let { groupsWithChildren.add(it) } + } + for (summary in active) { if (summary.notification.flags and Notification.FLAG_GROUP_SUMMARY == 0) continue val group = summary.notification.group ?: continue - val hasChildren = active.any { it.id != summary.id && it.notification.group == group } - if (!hasChildren) cancel(summary.id) + if (!group.startsWith(OWN_GROUP_PREFIX)) continue + if (group !in groupsWithChildren) cancel(summary.id) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/AccountScopedSingleSubNoEoseCacheEoseManager.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/AccountScopedSingleSubNoEoseCacheEoseManager.kt new file mode 100644 index 0000000000..c76800af32 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/AccountScopedSingleSubNoEoseCacheEoseManager.kt @@ -0,0 +1,46 @@ +/* + * 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.eoseManagers + +import com.vitorpamplona.amethyst.commons.relayClient.eoseManagers.SingleSubNoEoseCacheEoseManager +import com.vitorpamplona.amethyst.service.relayClient.AccountScopedQuery +import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient + +/** + * Amethyst variant of [SingleSubNoEoseCacheEoseManager] that restores single-account + * attribution for [AccountScopedQuery] keys. + * + * The commons base is account-agnostic (attribution defaults to null) so it can live in + * commonMain. Query states that carry an [Account] (home feed, channels, notifications, …) + * subclass this so their single-account REQs still show up attributed in "Active Relay + * Subscriptions". + * + * Keyed on [AccountScopedQuery] rather than a concrete query-state type: the home feed uses + * HomeQueryState, notifications use AccountQueryState, and checking one concrete class filed the + * other under "not attributed" despite both being built from a single account's data. + */ +abstract class AccountScopedSingleSubNoEoseCacheEoseManager( + client: INostrClient, + allKeys: () -> Set, + invalidateAfterEose: Boolean = false, +) : SingleSubNoEoseCacheEoseManager(client, allKeys, invalidateAfterEose) { + override fun accountPubKeyOf(key: Any?): String? = (key as? AccountScopedQuery)?.account?.userProfile()?.pubkeyHex +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/follows/FilterFindFollowMetadataForKey.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/follows/FilterFindFollowMetadataForKey.kt index 4d3ed060cd..f466cb4f17 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/follows/FilterFindFollowMetadataForKey.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/follows/FilterFindFollowMetadataForKey.kt @@ -23,13 +23,13 @@ package com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.follow import com.vitorpamplona.amethyst.commons.defaults.Constants import com.vitorpamplona.amethyst.commons.defaults.DefaultIndexerRelayList import com.vitorpamplona.amethyst.commons.defaults.DefaultSearchRelayList +import com.vitorpamplona.amethyst.commons.relayClient.user.pickRelaysToLoadUsers import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.service.relays.EOSEAccountFast import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl -import com.vitorpamplona.quartz.utils.mapOfSet fun pickRelaysToLoadUsers( users: Set, @@ -68,6 +68,7 @@ fun pickRelaysToLoadUsers( return pickRelaysToLoadUsers( users, + LocalCache.relayHints, indexRelays - cannotConnectRelays, homeRelays - cannotConnectRelays, searchRelays - cannotConnectRelays, @@ -77,121 +78,3 @@ fun pickRelaysToLoadUsers( hasTried, ) } - -fun pickRelaysToLoadUsers( - users: Set, - indexRelays: Set, - homeRelays: Set, - searchRelays: Set, - connected: Set, - commonRelays: Set, - cannotConnectRelays: Set, - hasTried: EOSEAccountFast, -): Map> = - mapOfSet { - users.forEachIndexed { _, key -> - val tried = (hasTried.since(key)?.keys ?: emptySet()) + cannotConnectRelays - - val outbox = key.authorRelayList()?.writeRelaysNorm() - - if (!outbox.isNullOrEmpty()) { - // If there is a home, get from it. - - // if it tried all outbox relays, stop. - // the UserWatch will take over from here. - val leftToTry = (outbox - tried) - leftToTry.forEach { - add(it, key.pubkeyHex) - } - } else { - // if not, tries hints first. - val hints = key.allUsedRelays() + LocalCache.relayHints.hintsForKey(key.pubkeyHex) - - val leftToTryOnHints = hints - tried - - leftToTryOnHints.forEach { - add(it, key.pubkeyHex) - } - - // if there are only a few hints, broadens the search - if (leftToTryOnHints.size < 3) { - // This creates a pre-deterministic order of the array such that - // if this function is called twice, it returns the same arrays - // which gets ignored by the relay client if we send it twice - val indexRelaysLeftToTry = - (indexRelays - tried).sortedBy { relay -> - key.pubkeyHex.hashCode() xor relay.url.hashCode() - } - // This creates a pre-deterministic order of the array such that - // if this function is called twice, it returns the same arrays - // which gets ignored by the relay client if we send it twice - val homeRelaysLeftToTry = - (homeRelays - tried).sortedBy { relay -> - key.pubkeyHex.hashCode() xor relay.url.hashCode() - } - - // picks one at random to avoid overloading these relays - if (users.size > 300) { - if (indexRelaysLeftToTry.size >= 2) { - add(indexRelaysLeftToTry[0], key.pubkeyHex) - add(indexRelaysLeftToTry[1], key.pubkeyHex) - } else if (indexRelaysLeftToTry.size == 1) { - add(indexRelaysLeftToTry.first(), key.pubkeyHex) - } - - homeRelaysLeftToTry.forEach { - add(it, key.pubkeyHex) - } - } else { - indexRelaysLeftToTry.forEach { - add(it, key.pubkeyHex) - } - - homeRelaysLeftToTry.forEach { - add(it, key.pubkeyHex) - } - } - - if (indexRelaysLeftToTry.size < 2) { - val searchRelaysLeftToTry = searchRelays - tried - - searchRelaysLeftToTry.forEach { - add(it, key.pubkeyHex) - } - - val connectedRelaysLeftToTry = - (connected - tried) - .sortedBy { relay -> - key.pubkeyHex.hashCode() xor relay.url.hashCode() - }.take(100) - - // picks one at random to avoid overloading these relays - if (users.size > 300) { - connectedRelaysLeftToTry.take(20).forEach { - add(it, key.pubkeyHex) - } - } else { - connectedRelaysLeftToTry.forEach { - add(it, key.pubkeyHex) - } - } - - if (searchRelaysLeftToTry.size < 2) { - // This creates a pre-deterministic order of the array such that - // if this function is called twice, it returns the same arrays - // which gets ignored by the relay client if we send it twice - val allRelaysLeftToTry = - (commonRelays - tried) - .sortedBy { relay -> - key.pubkeyHex.hashCode() xor relay.url.hashCode() - }.take(100) - - allRelaysLeftToTry.forEach { - add(it, key.pubkeyHex) - } - } - } - } - } - } - } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/channel/nip28PublicChats/ChannelLoaderSubAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/channel/nip28PublicChats/ChannelLoaderSubAssembler.kt index c14d14bd19..f06d9a28d7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/channel/nip28PublicChats/ChannelLoaderSubAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/channel/nip28PublicChats/ChannelLoaderSubAssembler.kt @@ -20,7 +20,7 @@ */ package com.vitorpamplona.amethyst.service.relayClient.reqCommand.channel.nip28PublicChats -import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.SingleSubNoEoseCacheEoseManager +import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.AccountScopedSingleSubNoEoseCacheEoseManager import com.vitorpamplona.amethyst.service.relayClient.reqCommand.channel.ChannelFinderQueryState import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter @@ -37,7 +37,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter class ChannelLoaderSubAssembler( client: INostrClient, allKeys: () -> Set, -) : SingleSubNoEoseCacheEoseManager(client, allKeys, invalidateAfterEose = true) { +) : AccountScopedSingleSubNoEoseCacheEoseManager(client, allKeys, invalidateAfterEose = true) { override fun updateFilter(keys: List): List = filterMissingChannelsById(keys) override fun distinct(key: ChannelFinderQueryState) = key.channel diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/EventFinderFilterAssemblerSubscription.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/EventFinderShims.kt similarity index 61% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/EventFinderFilterAssemblerSubscription.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/EventFinderShims.kt index 005f4f203e..b3a9458bab 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/EventFinderFilterAssemblerSubscription.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/EventFinderShims.kt @@ -21,30 +21,30 @@ package com.vitorpamplona.amethyst.service.relayClient.reqCommand.event import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember -import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.LifecycleAwareKeyDataSourceSubscription -import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.commons.relayClient.event.EventFinderFilterAssemblerSubscription import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +/** + * Back-compat aliases: the per-note event finder moved to commons + * (`com.vitorpamplona.amethyst.commons.relayClient.event`). Existing Android call + * sites that reference these by their old names resolve here. + */ +typealias EventFinderFilterAssembler = com.vitorpamplona.amethyst.commons.relayClient.event.EventFinderFilterAssembler + +typealias EventFinderQueryState = com.vitorpamplona.amethyst.commons.relayClient.event.EventFinderQueryState + +/** + * Android convenience overload: pulls the account + shared event-finder data source + * out of [accountViewModel] and delegates to the commons subscription. `Account` + * is-a `UserFinderAccount`, so no adaptation is needed. + */ @Composable fun EventFinderFilterAssemblerSubscription( note: Note, accountViewModel: AccountViewModel, -) = EventFinderFilterAssemblerSubscription(note, accountViewModel.account, accountViewModel.dataSources().eventFinder) - -@Composable -fun EventFinderFilterAssemblerSubscription( - note: Note, - account: Account, - dataSource: EventFinderFilterAssembler, -) { - // different screens get different states - // even if they are tracking the same tag. - val state = - remember(note, account) { - EventFinderQueryState(note, account) - } - - LifecycleAwareKeyDataSourceSubscription(state, dataSource) -} +) = EventFinderFilterAssemblerSubscription( + note, + accountViewModel.account, + accountViewModel.dataSources().eventFinder, +) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/nwc/NWCPaymentWatcherSubAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/nwc/NWCPaymentWatcherSubAssembler.kt index b179e5a042..51e4ae0e67 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/nwc/NWCPaymentWatcherSubAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/nwc/NWCPaymentWatcherSubAssembler.kt @@ -20,7 +20,7 @@ */ package com.vitorpamplona.amethyst.service.relayClient.reqCommand.nwc -import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.SingleSubNoEoseCacheEoseManager +import com.vitorpamplona.amethyst.commons.relayClient.eoseManagers.SingleSubNoEoseCacheEoseManager import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/UserFinderShims.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/UserFinderShims.kt new file mode 100644 index 0000000000..c9d01b3b68 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/UserFinderShims.kt @@ -0,0 +1,30 @@ +/* + * 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.reqCommand.user + +/** + * Back-compat aliases: the per-user metadata finder moved to commons + * (`com.vitorpamplona.amethyst.commons.relayClient.user`). Existing Android call + * sites that reference these by their old names resolve here. + */ +typealias UserFinderFilterAssembler = com.vitorpamplona.amethyst.commons.relayClient.user.UserFinderFilterAssembler + +typealias UserFinderQueryState = com.vitorpamplona.amethyst.commons.relayClient.user.UserFinderQueryState diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/searchCommand/subassemblies/FilterByAddress.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/searchCommand/subassemblies/FilterByAddress.kt index a527046727..df31d45479 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/searchCommand/subassemblies/FilterByAddress.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/searchCommand/subassemblies/FilterByAddress.kt @@ -20,9 +20,9 @@ */ package com.vitorpamplona.amethyst.service.relayClient.searchCommand.subassemblies +import com.vitorpamplona.amethyst.commons.relayClient.event.loaders.filterMissingAddressables +import com.vitorpamplona.amethyst.commons.relayClient.event.loaders.potentialRelaysToFindAddress import com.vitorpamplona.amethyst.model.LocalCache -import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.loaders.filterMissingAddressables -import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.loaders.potentialRelaysToFindAddress import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip19Bech32.entities.NAddress @@ -37,7 +37,7 @@ fun filterByAddress( val list = mapOfSet { if (note.event == null) { - potentialRelaysToFindAddress(note).ifEmpty { default }.forEach { relayUrl -> + potentialRelaysToFindAddress(LocalCache, note).ifEmpty { default }.forEach { relayUrl -> add(relayUrl, note.address) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/searchCommand/subassemblies/FilterByEvent.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/searchCommand/subassemblies/FilterByEvent.kt index 0a56940449..1bab42b2ef 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/searchCommand/subassemblies/FilterByEvent.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/searchCommand/subassemblies/FilterByEvent.kt @@ -20,10 +20,10 @@ */ package com.vitorpamplona.amethyst.service.relayClient.searchCommand.subassemblies +import com.vitorpamplona.amethyst.commons.relayClient.event.loaders.filterMissingEvents +import com.vitorpamplona.amethyst.commons.relayClient.event.loaders.potentialRelaysToFindEvent import com.vitorpamplona.amethyst.model.AddressableNote import com.vitorpamplona.amethyst.model.LocalCache -import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.loaders.filterMissingEvents -import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.loaders.potentialRelaysToFindEvent import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl @@ -38,16 +38,16 @@ fun filterByEvent( val list = mapOfSet { if (note !is AddressableNote && note.event == null) { - potentialRelaysToFindEvent(note).ifEmpty { default }.forEach { relayUrl -> + potentialRelaysToFindEvent(LocalCache, note).ifEmpty { default }.forEach { relayUrl -> add(relayUrl, note.idHex) } } // loads threading that is event-based note.replyTo?.forEach { parentNote -> - if (parentNote !is AddressableNote && note.event == null) { - potentialRelaysToFindEvent(note).ifEmpty { default }.forEach { relayUrl -> - add(relayUrl, note.idHex) + if (parentNote !is AddressableNote && parentNote.event == null) { + potentialRelaysToFindEvent(LocalCache, parentNote).ifEmpty { default }.forEach { relayUrl -> + add(relayUrl, parentNote.idHex) } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relays/EOSE.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relays/EOSE.kt index 403fcaf39f..f6032b8800 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relays/EOSE.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relays/EOSE.kt @@ -28,6 +28,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl typealias EOSERelayList = com.vitorpamplona.amethyst.commons.relays.EOSERelayList typealias SincePerRelayMap = com.vitorpamplona.amethyst.commons.relays.SincePerRelayMap typealias MutableTime = com.vitorpamplona.amethyst.commons.relays.MutableTime +typealias EOSEAccountFast = com.vitorpamplona.amethyst.commons.relays.EOSEAccountFast open class EOSEByKey( cacheSize: Int = 200, @@ -113,60 +114,3 @@ open class EOSEAccountKey( time: Long, ) = addOrUpdate(user, listCode, relayUrl, time) } - -class EOSEAccountFast( - cacheSize: Int = 20, -) { - private val users: LruCache = LruCache(cacheSize) - private val lock = Any() - - fun addOrUpdate( - user: T, - relayUrl: NormalizedRelayUrl, - time: Long, - ) { - synchronized(lock) { - val relayList = users[user] - if (relayList == null) { - val newList = EOSERelayList() - users.put(user, newList) - - newList.addOrUpdate(relayUrl, time) - } else { - relayList.addOrUpdate(relayUrl, time) - } - } - } - - fun removeEveryoneBut(list: Set) { - synchronized(lock) { - users.snapshot().forEach { - if (it.key !in list) { - users.remove(it.key) - } - } - } - } - - fun removeDataFor(user: T) { - synchronized(lock) { - users.remove(user) - } - } - - fun since(key: T): SincePerRelayMap? = - synchronized(lock) { - users[key]?.relayList?.toMutableMap() - } - - fun sinceRelaySet(key: T): Set? = - synchronized(lock) { - users[key]?.relayList?.keys?.toSet() - } - - fun newEose( - user: T, - relayUrl: NormalizedRelayUrl, - time: Long, - ) = addOrUpdate(user, relayUrl, time) -} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt index ee1ace2d8d..cf255db330 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt @@ -50,6 +50,9 @@ import androidx.navigation.compose.composable import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.nipACWebRtcCalls.CallState +import com.vitorpamplona.amethyst.commons.relayClient.event.LocalEventFinder +import com.vitorpamplona.amethyst.commons.relayClient.user.LocalUserFinder +import com.vitorpamplona.amethyst.commons.relayClient.user.LocalUserFinderAccount import com.vitorpamplona.amethyst.service.crashreports.DisplayCrashMessages import com.vitorpamplona.amethyst.service.relayClient.notifyCommand.compose.DisplayNotifyMessages import com.vitorpamplona.amethyst.service.resourceusage.DisplayResourceUsageAlert @@ -138,6 +141,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concor import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.ConcordCreateScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.ConcordEditScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.ConcordHomeScreen +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.ConcordInviteLinksScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.ConcordInviteScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.ConcordMembersScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.ephemChat.EphemeralChatScreen @@ -341,6 +345,15 @@ fun AppNavigation( CompositionLocalProvider( LocalScreenLayout provides screenLayout, LocalTabReselectCoordinator provides tabReselectCoordinator, + // Provide the shared finder CompositionLocals so any commons composable that + // uses the no-arg observeUser*/EventFinderFilterAssemblerSubscription(note) + // overloads works when rendered on Android (they error() if unprovided). Android's + // own UI uses the AccountViewModel overloads and doesn't strictly need these, but + // providing them removes the runtime trap for shared composables reaching the + // logged-in tree. (The :napplet process never renders these composables.) + LocalUserFinder provides accountViewModel.dataSources().userFinder, + LocalUserFinderAccount provides accountViewModel.account, + LocalEventFinder provides accountViewModel.dataSources().eventFinder, ) { AccountSwitcherAndLeftDrawerLayout(accountViewModel, accountSessionManager, nav) { Box(Modifier.fillMaxSize()) { @@ -747,6 +760,14 @@ fun BuildNavigation( ) } + composableFromEndArgs { + ConcordInviteLinksScreen( + communityId = it.communityId, + accountViewModel = accountViewModel, + nav = nav, + ) + } + composableFromEndArgs { ConcordEditScreen( communityId = it.communityId, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/navs/Nav.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/navs/Nav.kt index d937109b10..ac0adde7bc 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/navs/Nav.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/navs/Nav.kt @@ -121,7 +121,28 @@ class Nav( } // Mark this entry as a tab root: hides the back arrow in canPop // and skips the horizontal slide in composableFromEnd. - controller.getBackStackEntry(route).savedStateHandle[BOTTOM_NAV_ROOT_KEY] = true + // saveState/restoreState are keyed by DESTINATION, and every pinned tab of one kind shares a + // single destination — all web apps are `Route.WebApp/{url}`, all pinned chats their own one + // pattern. So the restore above can hand back a *sibling* tab's saved entry: with two web apps + // pinned, tapping the second one landed on the first one's URL, and the lookup below then threw + // `No destination with route …WebApp/ is on the NavController's back stack`. + // + // When the entry we asked for isn't there, take the tab fresh (no restoreState, and no + // launchSingleTop — the top is the sibling we do not want to reuse). Its saved scroll/ViewModel + // state is not recoverable in that case, but the user lands on the tab they tapped. Tabs whose + // destination nothing else shares still restore normally, which is what this is here for. + val entry = + runCatching { controller.getBackStackEntry(route) }.getOrNull() + ?: run { + controller.navigate(route) { + popUpTo(Route.Home) { + inclusive = false + saveState = true + } + } + runCatching { controller.getBackStackEntry(route) }.getOrNull() + } + entry?.savedStateHandle?.set(BOTTOM_NAV_ROOT_KEY, true) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt index b1afa1c5b9..c267a23c73 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt @@ -830,6 +830,10 @@ sealed class Route { val communityId: String, ) : Route() + @Serializable data class ConcordInviteLinks( + val communityId: String, + ) : Route() + @Serializable object ConcordCreate : Route() // Deep-link target for a Concord invite link (naddr#fragment). Opens the join flow. diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/share/ShareNoteAsQrScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/share/ShareNoteAsQrScreen.kt index 5bbf7bfdfa..03c78556de 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/share/ShareNoteAsQrScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/share/ShareNoteAsQrScreen.kt @@ -61,7 +61,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.qrcode.QrCodeDrawer import com.vitorpamplona.amethyst.ui.stringRes -// A cap, not a fixed size: QrCodeDrawer's own quiet zone (QR_MARGIN_PX in QrCodeDrawer.kt) is a +// A cap, not a fixed size: QrCodeDrawer's own quiet zone (QR_QUIET_ZONE_MODULES in QrCodeDrawer.kt) is a // fixed pixel count subtracted from raw size.width, so its share of the tile grows as density // falls. Hard-sizing this call to a small dp value starved long-form naddr payloads of scannable // resolution on low-density screens. Deriving the size from the available column width keeps diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordChannelListScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordChannelListScreen.kt index d8c94f5ace..57dffc1084 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordChannelListScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordChannelListScreen.kt @@ -170,10 +170,14 @@ fun ConcordChannelListScreen( // Rank alone isn't enough on a split epoch: publishing any Control edition also takes the // control_root (CORD-02 §2), which a freshly promoted staffer may not hold yet (CORD-04 §3), // so the affordance waits for the key too. + // hasPermission, never effectivePermissions: the latter reads the roles alone, so a banned + // moderator kept seeing every control here. The editions they authored were dropped by everyone's + // fold, which made these buttons silently no-op — worse than absent, and the same trap this file + // already avoids for the Roles… menu. val canManageChannels = state?.authority?.let { it.isOwner(account.signer.pubKey) || - it.effectivePermissions(account.signer.pubKey).has(ConcordPermissions.MANAGE_CHANNELS) + it.hasPermission(account.signer.pubKey, ConcordPermissions.MANAGE_CHANNELS) } == true && session?.controlPlaneKeys()?.canWrite == true @@ -242,10 +246,19 @@ fun ConcordChannelListScreen( val canEdit = state?.authority?.let { it.isOwner(account.signer.pubKey) || - it.effectivePermissions(account.signer.pubKey).has(ConcordPermissions.MANAGE_METADATA) + it.hasPermission(account.signer.pubKey, ConcordPermissions.MANAGE_METADATA) } == true && session?.controlPlaneKeys()?.canWrite == true + // Minting an invite hands out a working key to the community, so it takes + // CREATE_INVITE like any other privileged action. This button used to be the one + // control on the screen with no gate at all. + val canInvite = + state?.authority?.let { + it.isOwner(account.signer.pubKey) || + it.hasPermission(account.signer.pubKey, ConcordPermissions.CREATE_INVITE) + } == true + IconButton(onClick = { nav.nav(Route.ConcordMembers(communityId)) }) { SymbolIcon(symbol = MaterialSymbols.Group, contentDescription = stringRes(com.vitorpamplona.amethyst.R.string.concord_members_title)) } @@ -254,22 +267,24 @@ fun ConcordChannelListScreen( SymbolIcon(symbol = MaterialSymbols.Edit, contentDescription = stringRes(com.vitorpamplona.amethyst.R.string.concord_edit_title)) } } - IconButton( - enabled = !minting, - onClick = { - minting = true - scope.launch { - try { - inviteLink = account.concord.mintConcordInvite(communityId) - } finally { - // Always clear the flag — a thrown mint would otherwise leave the - // button disabled until the screen is recreated. - minting = false + if (canInvite) { + IconButton( + enabled = !minting, + onClick = { + minting = true + scope.launch { + try { + inviteLink = account.concord.mintConcordInvite(communityId) + } finally { + // Always clear the flag — a thrown mint would otherwise leave the + // button disabled until the screen is recreated. + minting = false + } } - } - }, - ) { - SymbolIcon(symbol = MaterialSymbols.PersonAdd, contentDescription = stringRes(com.vitorpamplona.amethyst.R.string.concord_invite_action)) + }, + ) { + SymbolIcon(symbol = MaterialSymbols.PersonAdd, contentDescription = stringRes(com.vitorpamplona.amethyst.R.string.concord_invite_action)) + } } // Overflow, mirroring the NIP-29 relay-group top bar: destructive membership @@ -279,6 +294,17 @@ fun ConcordChannelListScreen( SymbolIcon(symbol = MaterialSymbols.MoreVert, contentDescription = stringRes(com.vitorpamplona.amethyst.R.string.more_options)) } DropdownMenu(expanded = menuOpen, onDismissRequest = { menuOpen = false }) { + // Deliberately not gated on CREATE_INVITE, unlike minting: the links listed + // there are this account's own, authored by link-signer keys only we hold. + // Gating on the bit would mean a demoted admin could no longer retire the + // links they had already handed out — exactly when that matters most. + DropdownMenuItem( + text = { Text(stringRes(com.vitorpamplona.amethyst.R.string.concord_invite_links_action)) }, + onClick = { + menuOpen = false + nav.nav(Route.ConcordInviteLinks(communityId)) + }, + ) DropdownMenuItem( text = { Text( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordInviteLinksScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordInviteLinksScreen.kt new file mode 100644 index 0000000000..ef10b8c2bb --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordInviteLinksScreen.kt @@ -0,0 +1,273 @@ +/* + * 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.ui.screen.loggedIn.chats.publicChannels.concord + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalClipboard +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.ui.components.util.setText +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.quartz.concord.cord05Invites.ConcordInviteListEntry +import kotlinx.coroutines.launch +import java.text.DateFormat +import java.util.Date +import com.vitorpamplona.amethyst.commons.icons.symbols.Icon as SymbolIcon + +/** What the screen is currently showing. The unreadable case is deliberately not "empty" — see below. */ +private sealed interface LinksState { + data object Loading : LinksState + + data class Loaded( + val links: List, + ) : LinksState + + /** + * The kind-13303 list could not be read. Distinct from an empty list on purpose: rendering + * "no links yet" here would tell a creator that the link they came to kill does not exist. + */ + data object Unreadable : LinksState +} + +/** + * Every invite link this account minted for one community, with the ability to retire one + * (CORD-05 §2). + * + * The list is the creator's own kind-13303 Invite List, which is where a link's `signer_sk` lives — + * so this shows only links *this account* minted, from any of its devices. Another admin's links are + * invisible here and un-revokable from here, because the secret that authors their coordinate was + * never ours. That is a property of the protocol, not a gap in the screen. + * + * Fetched on entry rather than collected from a flow: nothing subscribes to kind 13303 (it is + * bookkeeping the user never sees), so there is no cache to observe. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ConcordInviteLinksScreen( + communityId: String, + accountViewModel: AccountViewModel, + nav: INav, +) { + val account = accountViewModel.account + val scope = rememberCoroutineScope() + val clipboard = LocalClipboard.current + + var state by remember(communityId) { mutableStateOf(LinksState.Loading) } + var reloads by remember(communityId) { mutableIntStateOf(0) } + var confirming by remember { mutableStateOf(null) } + var revoking by remember { mutableStateOf(false) } + + LaunchedEffect(communityId, reloads) { + state = LinksState.Loading + state = account.concord.listConcordInviteLinks(communityId)?.let { LinksState.Loaded(it) } ?: LinksState.Unreadable + } + + val communityName = + remember(account, communityId) { + account.concordChannelList.liveCommunities.value + .firstOrNull { it.id == communityId } + ?.name + .orEmpty() + } + + Scaffold( + topBar = { + TopAppBar( + title = { + Column { + Text(stringRes(R.string.concord_invite_links_title), fontWeight = FontWeight.Bold) + if (communityName.isNotBlank()) { + Text(communityName, style = MaterialTheme.typography.bodySmall, maxLines = 1, overflow = TextOverflow.Ellipsis) + } + } + }, + navigationIcon = { + IconButton(onClick = { nav.popBack() }) { + SymbolIcon(symbol = MaterialSymbols.AutoMirrored.ArrowBack, contentDescription = stringRes(R.string.back)) + } + }, + ) + }, + ) { padding -> + when (val current = state) { + is LinksState.Loading -> + Box(Modifier.fillMaxSize().padding(padding), contentAlignment = Alignment.Center) { + CircularProgressIndicator() + } + + is LinksState.Unreadable -> CenteredMessage(padding, stringRes(R.string.concord_invite_links_unreadable)) + + is LinksState.Loaded -> + if (current.links.isEmpty()) { + CenteredMessage(padding, stringRes(R.string.concord_invite_links_empty)) + } else { + LazyColumn(Modifier.fillMaxSize().padding(padding)) { + items(current.links, key = { it.token }) { link -> + InviteLinkRow( + link = link, + enabled = !revoking, + onCopy = { scope.launch { clipboard.setText(link.url) } }, + onRevoke = { confirming = link }, + ) + HorizontalDivider() + } + } + } + } + } + + confirming?.let { link -> + AlertDialog( + onDismissRequest = { if (!revoking) confirming = null }, + title = { Text(stringRes(R.string.concord_invite_revoke_title)) }, + text = { Text(stringRes(R.string.concord_invite_revoke_explainer)) }, + confirmButton = { + TextButton( + enabled = !revoking, + onClick = { + revoking = true + scope.launch { + try { + val ok = account.concord.revokeConcordInvite(communityId, link.token) + accountViewModel.toastManager.toast( + R.string.concord_invite_links_title, + if (ok) R.string.concord_invite_revoked_ok else R.string.concord_invite_revoked_failed, + ) + // Re-read either way: on success the link is gone from the list, and on + // failure the list is the only thing that can say whether it changed. + reloads++ + } finally { + revoking = false + confirming = null + } + } + }, + ) { + Text(stringRes(R.string.concord_invite_revoke_confirm), color = MaterialTheme.colorScheme.error) + } + }, + dismissButton = { + TextButton(enabled = !revoking, onClick = { confirming = null }) { + Text(stringRes(R.string.cancel)) + } + }, + ) + } +} + +@Composable +private fun CenteredMessage( + padding: PaddingValues, + message: String, +) { + Box(Modifier.fillMaxSize().padding(padding).padding(24.dp), contentAlignment = Alignment.Center) { + Text( + message, + // This Box sits on the bare window background, so LocalContentColor is still the M3 + // default black — see the sibling invite screen, where that made the text invisible. + color = MaterialTheme.colorScheme.onBackground, + style = MaterialTheme.typography.bodyLarge, + ) + } +} + +@Composable +private fun InviteLinkRow( + link: ConcordInviteListEntry, + enabled: Boolean, + onCopy: () -> Unit, + onRevoke: () -> Unit, +) { + var menuOpen by remember { mutableStateOf(false) } + + Row( + modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Column(Modifier.weight(1f).padding(end = 8.dp)) { + // The token prefix is what tells two links to the same community apart; their URLs share + // a long prefix, so they are useless as labels until well past where the row wraps. + Text(link.token.take(8), fontWeight = FontWeight.Bold, style = MaterialTheme.typography.bodyLarge) + Text( + stringRes(R.string.concord_invite_links_created, DateFormat.getDateInstance(DateFormat.MEDIUM).format(Date(link.createdAt * 1000))), + style = MaterialTheme.typography.bodySmall, + ) + Text(link.url, style = MaterialTheme.typography.bodySmall, maxLines = 1, overflow = TextOverflow.Ellipsis) + } + + IconButton(enabled = enabled, onClick = { menuOpen = true }) { + SymbolIcon(symbol = MaterialSymbols.MoreVert, contentDescription = stringRes(R.string.more_options)) + } + DropdownMenu(expanded = menuOpen, onDismissRequest = { menuOpen = false }) { + DropdownMenuItem( + text = { Text(stringRes(R.string.copy_to_clipboard)) }, + onClick = { + menuOpen = false + onCopy() + }, + ) + DropdownMenuItem( + text = { Text(stringRes(R.string.concord_invite_revoke_action), color = MaterialTheme.colorScheme.error) }, + onClick = { + menuOpen = false + onRevoke() + }, + ) + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordInviteScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordInviteScreen.kt index 2788dee7ee..7b19436713 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordInviteScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordInviteScreen.kt @@ -124,6 +124,8 @@ fun ConcordInviteScreen( RedeemState.Failed(R.string.concord_invite_failed_incompatible, canRetry = false) is ConcordInviteResult.Revoked -> RedeemState.Failed(R.string.concord_invite_failed_revoked, canRetry = false) + is ConcordInviteResult.Banned -> + RedeemState.Failed(R.string.concord_invite_failed_banned, canRetry = false) is ConcordInviteResult.Expired -> RedeemState.Failed(R.string.concord_invite_failed_expired, canRetry = false) is ConcordInviteResult.NotReachable -> @@ -161,6 +163,10 @@ fun ConcordInviteScreen( Text( stringRes(R.string.concord_redeeming_invite), modifier = Modifier.padding(top = 16.dp), + // Explicit: this Column sits on the bare window background with no Surface + // above it, so LocalContentColor is still the M3 default black — which renders + // every one of these labels invisible in the dark theme. + color = MaterialTheme.colorScheme.onBackground, textAlign = TextAlign.Center, ) } @@ -170,6 +176,7 @@ fun ConcordInviteScreen( Text( stringRes(failed.messageRes), style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onBackground, textAlign = TextAlign.Center, ) if (failed.canRetry) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordMembersScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordMembersScreen.kt index 57b146653b..a9e166c46c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordMembersScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordMembersScreen.kt @@ -133,7 +133,10 @@ fun ConcordMembersScreen( } val iAmOwner = state?.authority?.isOwner(myPubKey) == true - val iCanBan = state?.let { it.authority.isOwner(myPubKey) || it.authority.effectivePermissions(myPubKey).has(ConcordPermissions.BAN) } == true + // hasPermission, never effectivePermissions: a banned BAN-holder used to keep the whole Ban / + // Remove menu. It only stayed harmless because `canBanTarget` below routes through canActOn, + // which IS ban-aware — a thin margin for the escalation in docs/concord-soft-ban-audit.md. + val iCanBan = state?.let { it.authority.isOwner(myPubKey) || it.authority.hasPermission(myPubKey, ConcordPermissions.BAN) } == true val iCanManageRoles = state?.authority?.hasPermission(myPubKey, ConcordPermissions.MANAGE_ROLES) == true // The roles this viewer may actually hand out. The fold drops a grant whose granter does diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/hashtag/datasource/FilterHashtagLabels.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/hashtag/datasource/FilterHashtagLabels.kt index 670eff235a..7106576dfb 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/hashtag/datasource/FilterHashtagLabels.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/hashtag/datasource/FilterHashtagLabels.kt @@ -20,12 +20,12 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.hashtag.datasource +import com.vitorpamplona.amethyst.commons.relayClient.event.loaders.filterMissingEvents +import com.vitorpamplona.amethyst.commons.relayClient.event.loaders.potentialRelaysToFindEvent import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.LocalCache -import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.loaders.filterMissingEvents -import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.loaders.potentialRelaysToFindEvent import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl @@ -93,7 +93,7 @@ fun filterHashtagLabels( val target = LocalCache.getNoteIfExists(targetId) if (target?.event == null) { val targetNote = LocalCache.getOrCreateNote(targetId) - potentialRelaysToFindEvent(targetNote).ifEmpty { relays }.forEach { relayUrl -> + potentialRelaysToFindEvent(LocalCache, targetNote).ifEmpty { relays }.forEach { relayUrl -> add(relayUrl, targetId) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/NappletCapabilityExt.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/NappletCapabilityExt.kt index 66bce77ba8..201c818aa3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/NappletCapabilityExt.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/NappletCapabilityExt.kt @@ -26,7 +26,6 @@ import com.vitorpamplona.amethyst.commons.napplet.NappletCapability internal fun NappletCapability.symbol(): MaterialSymbol = when (this) { - NappletCapability.SHELL -> MaterialSymbols.Tune NappletCapability.IDENTITY -> MaterialSymbols.AccountCircle NappletCapability.KEYS -> MaterialSymbols.Key NappletCapability.RELAY -> MaterialSymbols.Public diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/qrcode/QrCodeDrawer.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/qrcode/QrCodeDrawer.kt index d4e624d926..a31a83b21c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/qrcode/QrCodeDrawer.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/qrcode/QrCodeDrawer.kt @@ -49,7 +49,15 @@ import com.google.zxing.qrcode.encoder.Encoder import com.google.zxing.qrcode.encoder.QRCode import com.vitorpamplona.amethyst.ui.theme.QuoteBorder -const val QR_MARGIN_PX = 100f +/** + * The quiet zone around the code, in **modules** — the QR spec's minimum of 4. + * + * It was a fixed 100px per side, which does not scale: at a small draw size those 200px ate most of + * the canvas, so a long payload (a Concord invite link, an nprofile) rendered as a postage stamp + * floating in white. Expressed in modules the zone stays proportional, so the code fills whatever + * box it is given at every size while remaining scannable. + */ +const val QR_QUIET_ZONE_MODULES = 4f @Preview @Composable @@ -78,13 +86,16 @@ fun QrCodeDrawer( ) { Canvas(modifier = Modifier.fillMaxSize()) { // Calculate the height and width of each column/row - val rowHeight = (size.width - QR_MARGIN_PX * 2f) / qrCode.matrix.height - val columnWidth = (size.width - QR_MARGIN_PX * 2f) / qrCode.matrix.width + // Solve for the module size with the quiet zone measured in modules, so the whole code + // (zone included) is exactly as wide as the canvas. + val rowHeight = size.height / (qrCode.matrix.height + QR_QUIET_ZONE_MODULES * 2f) + val columnWidth = size.width / (qrCode.matrix.width + QR_QUIET_ZONE_MODULES * 2f) val radius = CornerRadius(20f) // Draw all of the finder patterns required by the QR spec. Calculate the ratio // of the number of rows/columns to the width and height drawQrCodeFinders( + quietZonePx = columnWidth * QR_QUIET_ZONE_MODULES, sideLength = size.width, finderPatternSize = Size( @@ -97,6 +108,7 @@ fun QrCodeDrawer( // Draw data bits (encoded data part) drawAllQrCodeDataBits( + quietZonePx = columnWidth * QR_QUIET_ZONE_MODULES, bytes = qrCode.matrix, size = Size( @@ -119,7 +131,7 @@ private fun createQrCode(contents: String): QRCode { ErrorCorrectionLevel.Q, mapOf( EncodeHintType.CHARACTER_SET to "UTF-8", - EncodeHintType.MARGIN to QR_MARGIN_PX, + EncodeHintType.MARGIN to QR_QUIET_ZONE_MODULES, EncodeHintType.ERROR_CORRECTION to ErrorCorrectionLevel.Q, ), ) @@ -132,6 +144,7 @@ fun newPath(withPath: Path.() -> Unit) = } fun DrawScope.drawAllQrCodeDataBits( + quietZonePx: Float, bytes: ByteMatrix, size: Size, color: Color, @@ -182,8 +195,8 @@ fun DrawScope.drawAllQrCodeDataBits( Rect( offset = Offset( - x = QR_MARGIN_PX + x * size.width, - y = QR_MARGIN_PX + y * size.height, + x = quietZonePx + x * size.width, + y = quietZonePx + y * size.height, ), size = newSize, ), @@ -212,6 +225,7 @@ private const val INTERIOR_BACKGROUND_EXTERIOR_SHAPE_CORNER_RADIUS = 0.5f * @param finderPatternSize [Size] of each finder patten, based on the QR code spec */ internal fun DrawScope.drawQrCodeFinders( + quietZonePx: Float, sideLength: Float, finderPatternSize: Size, cornerRadius: CornerRadius, @@ -219,11 +233,11 @@ internal fun DrawScope.drawQrCodeFinders( ) { setOf( // Draw top left finder pattern. - Offset(x = QR_MARGIN_PX, y = QR_MARGIN_PX), + Offset(x = quietZonePx, y = quietZonePx), // Draw top right finder pattern. - Offset(x = sideLength - (QR_MARGIN_PX + finderPatternSize.width), y = QR_MARGIN_PX), + Offset(x = sideLength - (quietZonePx + finderPatternSize.width), y = quietZonePx), // Draw bottom finder pattern. - Offset(x = QR_MARGIN_PX, y = sideLength - (QR_MARGIN_PX + finderPatternSize.height)), + Offset(x = quietZonePx, y = sideLength - (quietZonePx + finderPatternSize.height)), ).forEach { offset -> drawQrCodeFinder( topLeft = offset, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/datasources/subassembies/FilterMissingEventsForThread.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/datasources/subassembies/FilterMissingEventsForThread.kt index c2a7d7ba40..a9de4c769d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/datasources/subassembies/FilterMissingEventsForThread.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/datasources/subassembies/FilterMissingEventsForThread.kt @@ -21,11 +21,12 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.threadview.datasources.subassembies import com.vitorpamplona.amethyst.commons.model.ThreadAssembler +import com.vitorpamplona.amethyst.commons.relayClient.event.loaders.filterMissingAddressables +import com.vitorpamplona.amethyst.commons.relayClient.event.loaders.filterMissingEvents +import com.vitorpamplona.amethyst.commons.relayClient.event.loaders.potentialRelaysToFindAddress +import com.vitorpamplona.amethyst.commons.relayClient.event.loaders.potentialRelaysToFindEvent import com.vitorpamplona.amethyst.model.AddressableNote -import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.loaders.filterMissingAddressables -import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.loaders.filterMissingEvents -import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.loaders.potentialRelaysToFindAddress -import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.loaders.potentialRelaysToFindEvent +import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.utils.mapOfSet @@ -37,14 +38,14 @@ fun filterMissingEventsForThread( val missingEvents = mapOfSet { if (threadInfo.root.event == null && threadInfo.root !is AddressableNote) { - potentialRelaysToFindEvent(threadInfo.root).ifEmpty { defaultRelays }.forEach { relayUrl -> + potentialRelaysToFindEvent(LocalCache, threadInfo.root).ifEmpty { defaultRelays }.forEach { relayUrl -> add(relayUrl, threadInfo.root.idHex) } } threadInfo.allNotes.forEach { if (it !is AddressableNote && it.event == null) { - potentialRelaysToFindEvent(it).ifEmpty { defaultRelays }.forEach { relayUrl -> + potentialRelaysToFindEvent(LocalCache, it).ifEmpty { defaultRelays }.forEach { relayUrl -> add(relayUrl, it.idHex) } } @@ -59,14 +60,14 @@ fun filterMissingEventsForThread( // note's aTag idHex into the hex-keyed event-hint index, which throws // on the non-hex string and kills the whole filter build — leaving a // thread opened on an uncached naddr permanently unfetched. - potentialRelaysToFindAddress(rootNote).ifEmpty { defaultRelays }.forEach { relayUrl -> + potentialRelaysToFindAddress(LocalCache, rootNote).ifEmpty { defaultRelays }.forEach { relayUrl -> add(relayUrl, rootNote.address) } } threadInfo.allNotes.forEach { if (it is AddressableNote && it.event == null) { - potentialRelaysToFindAddress(it).ifEmpty { defaultRelays }.forEach { relayUrl -> + potentialRelaysToFindAddress(LocalCache, it).ifEmpty { defaultRelays }.forEach { relayUrl -> add(relayUrl, it.address) } } diff --git a/amethyst/src/main/res/values-cs/strings.xml b/amethyst/src/main/res/values-cs/strings.xml index 2fce7e78c2..358ff5201b 100644 --- a/amethyst/src/main/res/values-cs/strings.xml +++ b/amethyst/src/main/res/values-cs/strings.xml @@ -3977,6 +3977,8 @@ Sdílet s vývojáři Pokud se zdá, že Amethyst spotřebovává baterii nebo data, můžete tento report odeslat vývojářům v šifrované DM. Obsahuje pouze čísla na této obrazovce a technická počítadla za nimi — žádné příspěvky, kontakty ani podrobnosti o prohlížení. Nic se neodešle, dokud v obrazovce zprávy neklepnete na Odeslat. Odeslat report přes DM + Kopírovat + Sdílet Zjištěno vysoké využití prostředků Amethyst nedávno spotřeboval více, než se očekávalo: %1$s. Chcete vývojářům odeslat report o využití v šifrované DM? Před odesláním čehokoli uvidíte celý report. %1$s mobilních dat na pozadí za jeden den diff --git a/amethyst/src/main/res/values-de-rDE/strings.xml b/amethyst/src/main/res/values-de-rDE/strings.xml index b8f2041354..d97a88f44c 100644 --- a/amethyst/src/main/res/values-de-rDE/strings.xml +++ b/amethyst/src/main/res/values-de-rDE/strings.xml @@ -1949,6 +1949,7 @@ Stöbern Medien + Hashtags Themen Unterhaltung Suche @@ -1979,8 +1980,10 @@ Standortbasierte Räume für die Gebiete, denen du folgst, abgefragt bei den Relays, die sie führen. Chat und Zap-Ziele, die an Live-Streams hängen, die du geöffnet hast oder denen du folgst. DM-Posteingang + Wallet Nutzap-Posteingang Mint-Verzeichnis + Wallet Connect Community-Chats Community-Feeds + Backlog Workflow-Läufe Agentenarbeit Neuer Lauf @@ -4646,6 +4659,7 @@ Benötigt deine Genehmigung Wartet auf Genehmigung (keine Beschreibung) + Workflow: %1$s von wartet auf Du bist der Genehmigende, aber diese Anmeldung kann keine Entscheidung signieren. @@ -4681,11 +4695,13 @@ Noch keine Workflows. Öffne das Menü oben und wähle „Neue Definition…“, um einen zu erstellen, und löse ihn dann aus. Was soll er tun? Lauf auslösen + Workflow Noch keine Workflows definiert Workflow auswählen Neue Definition… Neue Workflow-Definition Benennt ihn für den Kanal und veröffentlicht sein YAML-Rezept (kind-30620). Ein echtes Buzz-Relay führt das YAML aus. Selbst gehostet führt der Runner seinen konfigurierten Befehl aus — hier benennt und katalogisiert die Definition den Lauf nur. + Name build-und-test YAML-Rezept Die Definition konnte nicht veröffentlicht werden — prüfe, ob du in diesem Workspace posten kannst. diff --git a/amethyst/src/main/res/values-pt-rBR/strings.xml b/amethyst/src/main/res/values-pt-rBR/strings.xml index 1ceae75a2e..a7309a0c0e 100644 --- a/amethyst/src/main/res/values-pt-rBR/strings.xml +++ b/amethyst/src/main/res/values-pt-rBR/strings.xml @@ -1941,8 +1941,13 @@ + + %1$s \u00b7 %2$d relé + %1$s \u00b7 %2$d relés + Navegação Mídia + Hashtags Tópicos Conversa Pesquisa @@ -1976,6 +1981,7 @@ Carteira Caixa de entrada de nutzaps Diretório de mints + Wallet Connect Chats de comunidades Feeds de comunidades + Backlog Execuções de fluxo de trabalho Trabalho do agente Nova execução diff --git a/amethyst/src/main/res/values-sv-rSE/strings.xml b/amethyst/src/main/res/values-sv-rSE/strings.xml index 59ec28bf1d..bbef7915c4 100644 --- a/amethyst/src/main/res/values-sv-rSE/strings.xml +++ b/amethyst/src/main/res/values-sv-rSE/strings.xml @@ -3839,6 +3839,8 @@ Dela med utvecklarna Om Amethyst verkar dra batteri eller data kan du skicka den här rapporten till utvecklarna i ett krypterat DM. Den innehåller bara siffrorna på den här skärmen och de tekniska räknarna bakom dem — inga inlägg, kontakter eller surfdetaljer. Ingenting skickas förrän du trycker på Skicka i meddelandeskärmen. Skicka rapport via DM + Kopiera + Dela Hög resursanvändning upptäckt Amethyst förbrukade mer än väntat nyligen: %1$s. Vill du skicka en användningsrapport till utvecklarna i ett krypterat DM? Du får se hela rapporten innan något skickas. %1$s mobildata i bakgrunden på en dag diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index f3e96dffdf..9489821e4e 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -321,6 +321,18 @@ This invite link is invalid or can\'t be opened with this account. This invite link can\'t be opened. It may be outdated or already replaced by a newer one, or created with a newer version of the app. Ask for a fresh invite link. This invite link has been revoked and can no longer be used. Ask for a new one. + This community has removed you. The link still works, but its member list does not admit you. + Invite links + Invite links… + Created %1$s + You haven\'t created any invite links for this community yet. Links other admins created are managed on their own devices. + Your invite links couldn\'t be loaded, so none can be revoked right now. Check your connection and try again. + Revoke link + Revoke this link? + Anyone still holding this link will no longer be able to join. People who already joined with it stay in the community. This can\'t be undone. + Revoke + Invite link revoked. + The link couldn\'t be revoked. Check your connection and try again. This invite link has expired and can no longer be used. Ask for a fresh link. Community name is only revealed after you join Joining connects to this invite\'s relays, publishes a join announcement signed by your account, and adds the community to your list. Nothing is sent until you tap Join. diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/model/nip51Lists/PrecachedRelayListSeedTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/model/nip51Lists/PrecachedRelayListSeedTest.kt new file mode 100644 index 0000000000..89c0df7647 --- /dev/null +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/model/nip51Lists/PrecachedRelayListSeedTest.kt @@ -0,0 +1,91 @@ +/* + * 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.nip51Lists + +import com.vitorpamplona.amethyst.model.nip51Lists.searchRelays.SearchRelayListDecryptionCache +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal +import com.vitorpamplona.quartz.nip50Search.SearchRelayListEvent +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * Guards the non-suspending seed that [SearchRelayListState.flow] is initialized with. + * + * The seed exists so `.value` is never empty before the first async emission lands (see the + * KDoc on `flow`). For it to be an improvement over just using the curated defaults, reading an + * account's *public* relays must work with no signer involvement at all — otherwise a NIP-46 + * account would still block. These tests pin that property. + */ +class PrecachedRelayListSeedTest { + private val relayA = RelayUrlNormalizer.normalize("wss://relay.example.com") + private val relayB = RelayUrlNormalizer.normalize("wss://other.example.com") + + /** + * The load-bearing claim: public relay tags are readable synchronously. `cachedRelays` is + * non-suspending, so if this returned empty the seed would silently degrade to the defaults + * for every account. + */ + @Test + fun cachedRelays_readsPublicRelaysWithoutDecrypting() = + runTest { + val signer = NostrSignerInternal(KeyPair()) + val event = SearchRelayListEvent.create(relays = listOf(relayA, relayB), signer = signer) + + val relays = SearchRelayListDecryptionCache(signer).cachedRelays(event) + + assertEquals(setOf(relayA, relayB), relays) + } + + /** + * A kind:10007 with no relays must read as empty here, so the seed's `?.ifEmpty { null }` + * arm hands over to the curated defaults rather than seeding an empty set. + */ + @Test + fun cachedRelays_emptyListReadsEmptySoTheSeedCanFallBack() = + runTest { + val signer = NostrSignerInternal(KeyPair()) + val event = SearchRelayListEvent.create(relays = emptyList(), signer = signer) + + val relays = SearchRelayListDecryptionCache(signer).cachedRelays(event) + + assertTrue(relays.isEmpty()) + } + + /** + * Reading another account's list must not blow up or leak a decrypt attempt — the private + * cache refuses to build for a foreign pubkey, so only the public tags come back. + */ + @Test + fun cachedRelays_foreignAuthorStillYieldsPublicRelays() = + runTest { + val author = NostrSignerInternal(KeyPair()) + val reader = NostrSignerInternal(KeyPair()) + val event = SearchRelayListEvent.create(relays = listOf(relayA), signer = author) + + val relays = SearchRelayListDecryptionCache(reader).cachedRelays(event) + + assertEquals(setOf(relayA), relays) + } +} diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/napplet/NappletLauncherTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/napplet/NappletLauncherTest.kt new file mode 100644 index 0000000000..6145418d88 --- /dev/null +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/napplet/NappletLauncherTest.kt @@ -0,0 +1,68 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.napplet + +import android.content.Context +import android.content.Intent +import com.vitorpamplona.amethyst.napplethost.HostProfile +import com.vitorpamplona.quartz.nip5dNapplets.NappletManifest +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import org.junit.Assert.assertNull +import org.junit.Test + +class NappletLauncherTest { + private val context = mockk(relaxed = true) + private val manifest = mockk() + private val author = "aa".repeat(32) + + @Test + fun manifestLaunchRejectsNonEventManifest() { + NappletLauncher.launch(context, manifest, author, "demo") + + verify(exactly = 0) { context.startActivity(any()) } + } + + @Test + fun embeddedBuildLaunchParamsRejectsNonEventManifest() { + assertNull(NappletLauncher.buildLaunchParams(context, manifest, author, "demo")) + } + + @Test + fun rawLaunchRejectsNappletProfile() { + every { context.startActivity(any()) } returns Unit + + NappletLauncher.launch( + context = context, + paths = emptyList(), + servers = emptyList(), + authorPubKey = author, + identifier = "demo", + aggregateHash = null, + title = "Demo", + requires = emptyList(), + profile = HostProfile.NAPPLET, + ) + + verify(exactly = 0) { context.startActivity(any()) } + } +} diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/napplet/NappletProtocolJsonTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/napplet/NappletProtocolJsonTest.kt index 9fc041b421..211ea1a7be 100644 --- a/amethyst/src/test/java/com/vitorpamplona/amethyst/napplet/NappletProtocolJsonTest.kt +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/napplet/NappletProtocolJsonTest.kt @@ -24,6 +24,7 @@ import com.vitorpamplona.amethyst.commons.napplet.NappletCapability import com.vitorpamplona.amethyst.commons.napplet.protocol.NappletProtocolJson import com.vitorpamplona.amethyst.commons.napplet.protocol.NappletRequest import com.vitorpamplona.amethyst.commons.napplet.protocol.NappletResponse +import com.vitorpamplona.amethyst.commons.napplet.protocol.NappletStorageScope import com.vitorpamplona.quartz.nip01Core.core.Event import kotlinx.serialization.json.Json import kotlinx.serialization.json.JsonNull @@ -73,11 +74,6 @@ class NappletProtocolJsonTest { assertEquals(NappletRequest.GetPublicKey, NappletProtocolJson.decodeRequest("""{"type":"identity.getPublicKey","id":"1"}""")) } - @Test - fun decodesShellSupports() { - assertEquals(NappletRequest.ShellSupports("relay"), NappletProtocolJson.decodeRequest("""{"type":"shell.supports","id":"1","domain":"relay"}""")) - } - @Test fun decodesPublishFromAnUnsignedTemplateInTheEventField() { // @napplet/shim carries the unsigned template in the `event` field. The shell signs it. @@ -165,13 +161,22 @@ class NappletProtocolJsonTest { assertEquals(NappletRequest.StorageGet("k"), NappletProtocolJson.decodeRequest("""{"type":"storage.get","key":"k"}""")) assertEquals(NappletRequest.StorageSet("k", "v"), NappletProtocolJson.decodeRequest("""{"type":"storage.set","key":"k","value":"v"}""")) assertEquals(NappletRequest.StorageRemove("k"), NappletProtocolJson.decodeRequest("""{"type":"storage.remove","key":"k"}""")) - assertEquals(NappletRequest.StorageKeys, NappletProtocolJson.decodeRequest("""{"type":"storage.keys"}""")) + assertEquals(NappletRequest.StorageKeys(), NappletProtocolJson.decodeRequest("""{"type":"storage.keys"}""")) + assertEquals( + NappletRequest.StorageGet("k", NappletStorageScope.INSTANCE), + NappletProtocolJson.decodeRequest("""{"type":"storage.get","key":"k","scope":"instance"}"""), + ) } @Test fun decodesValueResourceUpload() { assertEquals(NappletRequest.PayInvoice("lnbc1"), NappletProtocolJson.decodeRequest("""{"type":"value.payInvoice","invoice":"lnbc1"}""")) + assertEquals(NappletRequest.ResourceInfo, NappletProtocolJson.decodeRequest("""{"type":"resource.info"}""")) assertEquals(NappletRequest.ResourceBytes("https://x"), NappletProtocolJson.decodeRequest("""{"type":"resource.bytes","url":"https://x"}""")) + assertEquals( + NappletRequest.ResourceBytesMany(listOf("https://x", "data:text/plain,hi")), + NappletProtocolJson.decodeRequest("""{"type":"resource.bytesMany","urls":["https://x","data:text/plain,hi"]}"""), + ) // "SGk=" is base64 for "Hi"; shell.html inlines the request Blob as request.dataBase64. val up = NappletProtocolJson.decodeRequest("""{"type":"upload.upload","request":{"dataBase64":"SGk=","mimeType":"text/plain","filename":"a.txt"}}""") as NappletRequest.UploadBlob assertEquals("text/plain", up.contentType) @@ -184,6 +189,7 @@ class NappletProtocolJsonTest { assertNull(NappletProtocolJson.decodeRequest("""{"type":"inc.emit","id":"1"}""")) // keys.signEvent is not a real domain method (keys = keyboard actions, not signing). assertNull(NappletProtocolJson.decodeRequest("""{"type":"keys.signEvent","id":"1"}""")) + assertNull(NappletProtocolJson.decodeRequest("""{"type":"identity.futureMethod","id":"1"}""")) assertNull(NappletProtocolJson.decodeRequest("""{"foo":"bar"}""")) } @@ -211,7 +217,9 @@ class NappletProtocolJsonTest { assertEquals("s1", ev["subId"]?.jsonPrimitive?.content) assertEquals( "a".repeat(64), - ev["event"] + ev["result"] + ?.jsonObject + ?.get("event") ?.jsonObject ?.get("id") ?.jsonPrimitive @@ -233,13 +241,6 @@ class NappletProtocolJsonTest { assertEquals("pk", o["pubkey"]?.jsonPrimitive?.content) } - @Test - fun encodesSupported() { - val o = json.parseToJsonElement(NappletProtocolJson.encodeResponse("shell.supports", NappletResponse.Supported(true))).jsonObject - assertEquals("shell.supports.result", o["type"]?.jsonPrimitive?.content) - assertTrue(o["supported"]!!.jsonPrimitive.boolean) - } - @Test fun encodesPublishedEventAndEvents() { // relay.publish resolves to the signed event (matching upstream NostrEvent return). @@ -257,6 +258,18 @@ class NappletProtocolJsonTest { val events = json.parseToJsonElement(NappletProtocolJson.encodeResponse("relay.query", NappletResponse.Events(listOf(sampleEvent())))).jsonObject assertEquals(1, events["events"]?.jsonArray?.size) + assertEquals( + "a".repeat(64), + events["events"] + ?.jsonArray + ?.first() + ?.jsonObject + ?.get("event") + ?.jsonObject + ?.get("id") + ?.jsonPrimitive + ?.content, + ) } @Test @@ -269,6 +282,71 @@ class NappletProtocolJsonTest { assertEquals(2, keys["keys"]?.jsonArray?.size) } + @Test + fun encodesResourceInfoBulkItemsAndTypedErrors() { + val info = + json + .parseToJsonElement( + NappletProtocolJson.encodeResponse( + "resource.info", + NappletResponse.ResourceInfo(listOf("https"), 10L * 1024L * 1024L, 16), + ), + ).jsonObject + assertEquals( + "https", + info["info"] + ?.jsonObject + ?.get("schemes") + ?.jsonArray + ?.first() + ?.jsonObject + ?.get("scheme") + ?.jsonPrimitive + ?.content, + ) + + val items = + json + .parseToJsonElement( + NappletProtocolJson.encodeResponse( + "resource.bytesMany", + NappletResponse.ResourceItems( + listOf( + NappletResponse.ResourceItem("https://x", NappletResponse.Bytes("Hi".encodeToByteArray(), "text/plain")), + NappletResponse.ResourceItem("https://y", error = "not-found"), + ), + ), + ), + ).jsonObject["items"] + ?.jsonArray + assertEquals( + "SGk=", + items + ?.first() + ?.jsonObject + ?.get("bytes") + ?.jsonPrimitive + ?.content, + ) + assertEquals( + "not-found", + items + ?.get(1) + ?.jsonObject + ?.get("error") + ?.jsonPrimitive + ?.content, + ) + + val failure = + json + .parseToJsonElement( + NappletProtocolJson.encodeResponse("resource.bytes", NappletResponse.ResourceFailure("blocked-by-policy", "private target")), + ).jsonObject + assertEquals("resource.bytes.error", failure["type"]?.jsonPrimitive?.content) + assertEquals("blocked-by-policy", failure["error"]?.jsonPrimitive?.content) + } + @Test fun encodesBytesAsBase64WithMime() { val o = json.parseToJsonElement(NappletProtocolJson.encodeResponse("resource.bytes", NappletResponse.Bytes("Hi".encodeToByteArray(), "text/plain"))).jsonObject diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/napplet/NappletRelayCleartextTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/napplet/NappletRelayCleartextTest.kt new file mode 100644 index 0000000000..d2de5606d6 --- /dev/null +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/napplet/NappletRelayCleartextTest.kt @@ -0,0 +1,108 @@ +/* + * 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.napplet + +import com.vitorpamplona.quartz.nip01Core.core.Event +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertSame +import org.junit.Test + +class NappletRelayCleartextTest { + @Test + fun plaintextPassesThroughWithoutDecrypting() = + runTest { + val event = event(content = "hello") + val result = + NappletRelayCleartext.forDelivery(event, USER) { _, _ -> + error("plaintext must not be decrypted") + } + + assertSame(event, result) + } + + @Test + fun inboundNip04IsProjectedAsCleartext() = + runTest { + val event = event(content = NIP04, tags = arrayOf(arrayOf("p", USER))) + val result = + NappletRelayCleartext.forDelivery(event, USER) { ciphertext, peer -> + assertEquals(NIP04, ciphertext) + assertEquals(AUTHOR, peer) + "secret" + } + + assertEquals("secret", result?.content) + assertEquals(event.id, result?.id) + assertEquals(event.sig, result?.sig) + } + + @Test + fun outboundEncryptedEventUsesItsRecipientAsPeer() = + runTest { + val event = event(pubKey = USER, content = NIP04, tags = arrayOf(arrayOf("p", RECIPIENT))) + val result = + NappletRelayCleartext.forDelivery(event, USER) { _, peer -> + assertEquals(RECIPIENT, peer) + "sent secret" + } + + assertEquals("sent secret", result?.content) + } + + @Test + fun encryptedEventForAnotherUserIsWithheld() = + runTest { + val event = event(content = NIP04, tags = arrayOf(arrayOf("p", RECIPIENT))) + val result = + NappletRelayCleartext.forDelivery(event, USER) { _, _ -> + error("unrelated ciphertext must not be offered to the signer") + } + + assertNull(result) + } + + @Test + fun decryptionFailureWithholdsCiphertext() = + runTest { + val event = event(content = NIP04, tags = arrayOf(arrayOf("p", USER))) + val result = + NappletRelayCleartext.forDelivery(event, USER) { _, _ -> + error("signer refused") + } + + assertNull(result) + } + + private fun event( + pubKey: String = AUTHOR, + content: String, + tags: Array> = emptyArray(), + ) = Event("id", pubKey, 1L, 4, tags, content, "sig") + + companion object { + private const val USER = "user" + private const val AUTHOR = "author" + private const val RECIPIENT = "recipient" + private const val NIP04 = "ciphertext-that-is-long-enough?iv=123456789012345678901234" + } +} diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/napplet/NappletSdkConformanceTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/napplet/NappletSdkConformanceTest.kt index fe65a51e6c..82f99a1a98 100644 --- a/amethyst/src/test/java/com/vitorpamplona/amethyst/napplet/NappletSdkConformanceTest.kt +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/napplet/NappletSdkConformanceTest.kt @@ -24,6 +24,7 @@ import com.vitorpamplona.amethyst.commons.napplet.NappletCapability import com.vitorpamplona.amethyst.commons.napplet.protocol.NappletProtocolJson import com.vitorpamplona.amethyst.commons.napplet.protocol.NappletRequest import com.vitorpamplona.amethyst.commons.napplet.protocol.NappletResponse +import com.vitorpamplona.amethyst.commons.napplet.protocol.NappletStorageScope import com.vitorpamplona.quartz.nip01Core.core.Event import kotlinx.serialization.json.Json import kotlinx.serialization.json.jsonArray @@ -114,17 +115,31 @@ class NappletSdkConformanceTest { // RelayQueryResultMessage: { type:'relay.query.result', id, events, error? } val o = result("relay.query", NappletResponse.Events(listOf(sampleEvent()))) assertEquals(1, o["events"]?.jsonArray?.size) + assertEquals( + "a".repeat(64), + o["events"] + ?.jsonArray + ?.first() + ?.jsonObject + ?.get("event") + ?.jsonObject + ?.get("id") + ?.jsonPrimitive + ?.content, + ) } @Test fun relayEventAndEosePushesMatchTheSdk() { - // RelayEventMessage (PUSH): { type:'relay.event', subId, event } + // RelayEventMessage (PUSH): { type:'relay.event', subId, result:{event, sidecar?} } val ev = json.parseToJsonElement(NappletProtocolJson.encodeRelayEvent("s1", sampleEvent())).jsonObject assertEquals("relay.event", ev["type"]?.jsonPrimitive?.content) assertEquals("s1", ev["subId"]?.jsonPrimitive?.content) assertEquals( "a".repeat(64), - ev["event"] + ev["result"] + ?.jsonObject + ?.get("event") ?.jsonObject ?.get("id") ?.jsonPrimitive @@ -175,7 +190,11 @@ class NappletSdkConformanceTest { assertEquals(NappletRequest.StorageGet("k"), NappletProtocolJson.decodeRequest("""{"type":"storage.get","id":"1","key":"k"}""")) assertEquals(NappletRequest.StorageSet("k", "v"), NappletProtocolJson.decodeRequest("""{"type":"storage.set","id":"1","key":"k","value":"v"}""")) assertEquals(NappletRequest.StorageRemove("k"), NappletProtocolJson.decodeRequest("""{"type":"storage.remove","id":"1","key":"k"}""")) - assertEquals(NappletRequest.StorageKeys, NappletProtocolJson.decodeRequest("""{"type":"storage.keys","id":"1"}""")) + assertEquals(NappletRequest.StorageKeys(), NappletProtocolJson.decodeRequest("""{"type":"storage.keys","id":"1"}""")) + assertEquals( + NappletRequest.StorageGet("k", NappletStorageScope.INSTANCE), + NappletProtocolJson.decodeRequest("""{"type":"storage.get","id":"1","key":"k","scope":"instance"}"""), + ) // StorageGetResultMessage.value, StorageKeysResultMessage.keys assertTrue(result("storage.get", NappletResponse.StorageValue("v")).containsKey("value")) @@ -186,7 +205,12 @@ class NappletSdkConformanceTest { @Test fun resourceBytesRequestAndResultMatch() { + assertEquals(NappletRequest.ResourceInfo, NappletProtocolJson.decodeRequest("""{"type":"resource.info","id":"0"}""")) assertEquals(NappletRequest.ResourceBytes("https://x"), NappletProtocolJson.decodeRequest("""{"type":"resource.bytes","id":"1","url":"https://x"}""")) + assertEquals( + NappletRequest.ResourceBytesMany(listOf("https://x", "data:text/plain,hi")), + NappletProtocolJson.decodeRequest("""{"type":"resource.bytesMany","id":"2","urls":["https://x","data:text/plain,hi"]}"""), + ) // The host emits base64 bytes + mime; shell.html rebuilds the Blob the SDK expects. val o = result("resource.bytes", NappletResponse.Bytes("Hi".encodeToByteArray(), "text/plain")) assertEquals("SGk=", o["bytes"]?.jsonPrimitive?.content) @@ -203,29 +227,6 @@ class NappletSdkConformanceTest { assertTrue(result("relay.query", NappletResponse.Failed("boom")).containsKey("error")) } - // ---------- shell handshake (ShellReadyMessage / ShellInitMessage) ---------- - - @Test - fun shellInitAdvertisesTheCapabilityEnvironment() { - // shell.ready is answered by the host (not the codec) with this shell.init env, which the - // SDK caches and answers shell.supports() from locally. ShellInitMessage: - // { type:'shell.init', capabilities:{ domains, protocols }, services }. - val o = json.parseToJsonElement(NappletProtocolJson.encodeShellInit(listOf("shell", "relay"), listOf("shell", "relay"))).jsonObject - assertEquals("shell.init", o["type"]?.jsonPrimitive?.content) - assertEquals( - 2, - o["capabilities"] - ?.jsonObject - ?.get("domains") - ?.jsonArray - ?.size, - ) - assertTrue(o["capabilities"]?.jsonObject?.containsKey("protocols") == true) - assertEquals(2, o["services"]?.jsonArray?.size) - // shell.ready stays a host-layer message — the codec doesn't treat it as a broker request. - assertNull(NappletProtocolJson.decodeRequest("""{"type":"shell.ready"}""")) - } - // ---------- keys (keyboard/command actions) ---------- @Test diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/napplet/gateways/NappletResourceFetcherPolicyTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/napplet/gateways/NappletResourceFetcherPolicyTest.kt new file mode 100644 index 0000000000..5f8643317d --- /dev/null +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/napplet/gateways/NappletResourceFetcherPolicyTest.kt @@ -0,0 +1,70 @@ +/* + * 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.napplet.gateways + +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import java.net.InetAddress + +class NappletResourceFetcherPolicyTest { + @Test + fun blocksPrivateSpecialAndLocalAddresses() { + val blocked = + listOf( + "0.0.0.0", + "10.0.0.1", + "100.64.0.1", + "127.0.0.1", + "169.254.169.254", + "172.16.0.1", + "192.0.0.1", + "192.0.2.1", + "192.168.1.1", + "198.18.0.1", + "198.51.100.1", + "203.0.113.1", + "224.0.0.1", + "::1", + "2001:db8::1", + "fc00::1", + "fe80::1", + ) + + blocked.forEach { + assertFalse(it, NappletResourceFetcher.isPublicAddress(InetAddress.getByName(it))) + } + } + + @Test + fun permitsPublicAddresses() { + assertTrue(NappletResourceFetcher.isPublicAddress(InetAddress.getByName("1.1.1.1"))) + assertTrue(NappletResourceFetcher.isPublicAddress(InetAddress.getByName("2606:4700:4700::1111"))) + } + + @Test + fun acceptsOnlyCredentialFreeHttpsUrls() { + assertTrue(NappletResourceFetcher.isSafeHttpsResourceUrl("https://example.com/a")) + assertFalse(NappletResourceFetcher.isSafeHttpsResourceUrl("http://example.com/a")) + assertFalse(NappletResourceFetcher.isSafeHttpsResourceUrl("https://user:secret@example.com/a")) + assertFalse(NappletResourceFetcher.isSafeHttpsResourceUrl("file:///etc/passwd")) + } +} diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/loaders/AddressableAuthorRelayLoaderSubAssemblerTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/loaders/AddressableAuthorRelayLoaderSubAssemblerTest.kt index 842471e6a4..82fd16f813 100644 --- a/amethyst/src/test/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/loaders/AddressableAuthorRelayLoaderSubAssemblerTest.kt +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/loaders/AddressableAuthorRelayLoaderSubAssemblerTest.kt @@ -20,11 +20,12 @@ */ package com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.loaders +import com.vitorpamplona.amethyst.commons.relayClient.event.loaders.AddressableAuthorRelayLoaderSubAssembler +import com.vitorpamplona.amethyst.commons.relayClient.user.UserFinderFilterAssembler +import com.vitorpamplona.amethyst.commons.relayClient.user.UserFinderQueryState import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.EventFinderQueryState -import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.UserFinderFilterAssembler -import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.UserFinderQueryState import com.vitorpamplona.quartz.nip01Core.core.Address import io.mockk.every import io.mockk.mockk diff --git a/cli/README.md b/cli/README.md index d3f0591f78..76ec8f2844 100644 --- a/cli/README.md +++ b/cli/README.md @@ -669,6 +669,7 @@ also carried on-relay as an encrypted kind:13302. | `amy concord send COMMUNITY CHANNEL TEXT` | Post a message (CHANNEL = `general`\|name\|id). | | `amy concord read COMMUNITY CHANNEL [--limit N] [--epoch N] [--root HEX]` | Read a channel's messages (default 50); `--epoch`/`--root` read a prior epoch's plane. | | `amy concord invite COMMUNITY [--base URL]` | Mint + publish a shareable invite link. | +| `amy concord revoke COMMUNITY TOKEN\|URL` | Retire a link you minted: publishes a `vsk=9` tombstone at its coordinate, then records it in your Invite List. | | `amy concord join URL` | Redeem an invite link and save the community. | | `amy concord roles COMMUNITY` | List live roles + the current banlist (CORD-04). | | `amy concord role COMMUNITY NAME POSITION PERM…` | Define a role (perms by name, e.g. `BAN KICK`). | diff --git a/cli/ROADMAP.md b/cli/ROADMAP.md index add1d87d06..590964f6d4 100644 --- a/cli/ROADMAP.md +++ b/cli/ROADMAP.md @@ -67,7 +67,7 @@ Status legend: ✅ shipped · 📦 logic lives in `commons/`, needs a command · | NIP-65 outbox model queries | ✅ | `OutboxCommand` — `amy outbox USER [--refresh]`, cache-first. | | CLINK offers + debits (`amy offer` / `amy debit`) | ✅ | `OfferCommands` + `DebitCommands` — pointer decode, NIP-05 discover, kind:21001/21002 round-trips, `offer pay --with NDEBIT` end-to-end settlement. `--timeout` is SECONDS. | | Geochat (Bitchat geohash, ephemeral kind:20000) | ✅ | `GeochatCommands` — listen/send/keys with per-geohash throwaway identity + geo-nearest relay routing; doubles as the Bitchat interop harness. | -| Concord Channels (encrypted communities) | ✅ | `ConcordCommands` — 13 sub-verbs (create/list/import/channels/send/read/invite/join/roles/role/grant/ban/unban) over shared `commons` `ConcordActions`; secrets in `concord.json`. | +| Concord Channels (encrypted communities) | ✅ | `ConcordCommands` — 17 sub-verbs (create/list/import/channels/send/read/invite/revoke/join/recover/rekey/roles/role/grant/ban/unban/refound) over shared `commons` `ConcordActions`; secrets in `concord.json`. | | NIP-5A nsites + NIP-5D napplets | ✅ | `NsiteCommands` + `NappletCommands` — fetch/publish/serve/list with sha256 + aggregate-hash verification and `requires` capability reporting. | | Podcasting 2.0 / podstr (`amy podcast20`) | ✅ | `Podcast20Commands` — kind:30078 metadata, 30054 episodes, 30055 trailers, list. | | Follows-of-follows (`amy fof get/list/sync`) | ✅ | `FofCommand` — single-hop social proof from the local store (`wot` kept as deprecation alias). | diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Context.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Context.kt index 02b482bebf..185cbc7f6d 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Context.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Context.kt @@ -582,12 +582,15 @@ class Context( diagnoseSlow: Boolean = false, deadOut: MutableMap? = null, pendingOnAuthRequired: Boolean = false, + /** Per-relay terminal reason, so a caller can tell an empty answer from no answer. */ + doneOut: MutableMap? = null, ): List> = client.fetchAllWithHooks( filters = filters, idleTimeoutMs = idleTimeoutMs, pendingOnAuthRequired = pendingOnAuthRequired, deadOut = deadOut, + doneOut = doneOut, onTimeout = if (diagnoseSlow) { { stalled, doneReasons, collected -> logSlowDrain(idleTimeoutMs, stalled, doneReasons, collected) } diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt index 5b14b8da61..0ddc3f5762 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt @@ -869,6 +869,7 @@ private fun printUsage() { | concord send COMMUNITY CHANNEL TEXT post a message (CHANNEL = general|name|id) | concord read COMMUNITY CHANNEL [--limit N] read a channel's messages | concord invite COMMUNITY [--base URL] mint + publish a shareable invite link + | concord revoke COMMUNITY TOKEN|URL retire a link you minted (vsk=9 tombstone) | concord join URL redeem an invite link and save the community | |Local event store (shared, under `/shared/`): diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordCommands.kt index ff151176ef..51ad02fd7e 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordCommands.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordCommands.kt @@ -28,9 +28,22 @@ import com.vitorpamplona.amethyst.cli.stores.ConcordStore import com.vitorpamplona.amethyst.cli.stores.StoredCommunity import com.vitorpamplona.amethyst.cli.stores.StoredHeldRoot import com.vitorpamplona.amethyst.commons.actions.ConcordActions +import com.vitorpamplona.amethyst.commons.actions.ConcordReceive +import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEntry import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEvent +import com.vitorpamplona.quartz.concord.cord02Community.HeldRoot +import com.vitorpamplona.quartz.concord.cord04Roles.AuthorityResolver +import com.vitorpamplona.quartz.concord.cord04Roles.ControlEdition +import com.vitorpamplona.quartz.concord.cord05Invites.ConcordInviteList +import com.vitorpamplona.quartz.concord.cord05Invites.ConcordInviteListDocument +import com.vitorpamplona.quartz.concord.cord05Invites.ConcordInviteListEntry +import com.vitorpamplona.quartz.concord.cord05Invites.ConcordInviteListEvent +import com.vitorpamplona.quartz.concord.cord05Invites.ConcordInviteListTombstone +import com.vitorpamplona.quartz.concord.cord05Invites.InviteBundleStatus +import com.vitorpamplona.quartz.concord.crypto.ControlPlaneKeys import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.anyRelayServed import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer @@ -55,12 +68,23 @@ object ConcordCommands { | concord read COMMUNITY CHANNEL [--limit N] read a channel's messages (default 50); | [--epoch N] [--root HEX] --epoch/--root read a prior epoch's plane | concord invite COMMUNITY [--base URL] mint + publish a shareable invite link + | concord revoke COMMUNITY TOKEN|URL retire a link you minted: publishes a vsk=9 + | tombstone at its coordinate, then tombstones + | it in your invite list so it stays retired | concord join URL redeem an invite link and save the community + | concord rekey [COMMUNITY] follow a Refounding we were re-keyed for: + | open our blob and adopt the new epoch + | concord recover [COMMUNITY] re-resolve the joined-through invite link and + | follow a Refounding we were left out of + | (CORD-06); refuses if that epoch banned us | concord roles COMMUNITY list live roles + current banlist (CORD-04) | concord role COMMUNITY NAME POSITION PERM… define a role (perms by name, e.g. BAN KICK) | concord grant COMMUNITY USER ROLE-ID grant a role to a member | concord ban COMMUNITY USER ban a member | concord unban COMMUNITY USER unban a member + | concord refound COMMUNITY --remove U[,U] CORD-06 Refounding: rotate the root (and the + | control_root) so removed members lose every + | key — the hard removal a ban cannot give """.trimMargin() suspend fun dispatch( @@ -70,7 +94,7 @@ object ConcordCommands { route( "concord", tail, - "concord ", + "concord ", help = USAGE, routes = mapOf( @@ -81,12 +105,16 @@ object ConcordCommands { "send" to { rest -> ConcordChannelCommands.send(dataDir, rest) }, "read" to { rest -> ConcordChannelCommands.read(dataDir, rest) }, "invite" to { rest -> invite(dataDir, rest) }, + "revoke" to { rest -> revoke(dataDir, rest) }, "join" to { rest -> join(dataDir, rest) }, + "recover" to { rest -> recover(dataDir, rest) }, + "rekey" to { rest -> rekey(dataDir, rest) }, "roles" to { rest -> ConcordModCommands.roles(dataDir, rest) }, "role" to { rest -> ConcordModCommands.defineRole(dataDir, rest) }, "grant" to { rest -> ConcordModCommands.grant(dataDir, rest) }, "ban" to { rest -> ConcordModCommands.ban(dataDir, rest) }, "unban" to { rest -> ConcordModCommands.unban(dataDir, rest) }, + "refound" to { rest -> ConcordModCommands.refound(dataDir, rest) }, ), ) @@ -208,7 +236,10 @@ object ConcordCommands { controlRoot = e.controlRoot ?: priorSameEpoch?.controlRoot ?: "", generalChannelId = prior?.generalChannelId ?: "", relays = e.relays, - heldRoots = e.heldRoots.map { StoredHeldRoot(it.epoch, it.key, it.controlPk ?: "") }, + heldRoots = e.heldRoots.map { StoredHeldRoot(it.epoch, it.key, it.controlPk ?: "", it.controlRoot ?: "") }, + // Survives every merge: losing the anchor makes the NEXT exclusion + // unrecoverable, so a list entry without one must not clear ours. + inviteRef = e.inviteRef ?: prior?.inviteRef ?: "", ), ) mapOf( @@ -241,6 +272,33 @@ object ConcordCommands { // (CORD-05 §1); omitted for a legacy community, which has none to carry. val invite = ConcordActions.inviteFor(sc.communityId, sc.owner, sc.ownerSalt, sc.root, sc.rootEpoch, sc.name, sc.relays, sc.controlPk.ifBlank { null }) val minted = ConcordActions.mintInviteLink(base, invite, TimeUtils.now(), sc.relays) + // Record the link BEFORE publishing the bundle (CORD-05, kind 13303): a link whose + // `signer_sk` was never stored can never be refreshed, so the next Refounding orphans + // it and every holder is stranded. Better to mint nothing than to hand out a link that + // is already doomed. + val recorded = + publishInviteList( + ctx, + ConcordInviteListDocument( + entries = + listOf( + ConcordInviteListEntry( + token = minted.token.toHexKey(), + signerSk = minted.linkSignerPrivKey.toHexKey(), + communityId = sc.communityId, + url = minted.url, + createdAt = TimeUtils.now(), + ), + ), + ), + ) + if (!recorded) { + return Output.error( + "invite_unrecordable", + "could not record the link signer in your invite list (kind 13303), so this link could never be refreshed after a Refounding — not minting it", + ) + } + val ack = ctx.publish(minted.bundleEvent, relaysFor(ctx, sc)) RawEventSupport.publishGuard(ack, minted.bundleEvent.id)?.let { return it } @@ -255,6 +313,92 @@ object ConcordCommands { } } + /** + * `amy concord revoke ` — retires one link this account minted. + * + * Two records have to agree for a link to be gone, and they fail differently, so the order is + * deliberate. The wire tombstone (`vsk=9` at the link's own coordinate) is what actually stops + * a join, and publishing it needs the `signer_sk` that only the kind-13303 Invite List holds. + * The list tombstone is bookkeeping: it stops a later Refounding from re-minting the link. + * + * So the wire goes first and the list second. The reverse order would delete the entry — a + * merge drops a tombstoned token's entry terminally — and if the publish then failed, the link + * would stay live with its `signer_sk` gone and no way left to retire it. A failed list write + * is recoverable by comparison: the link is already dead on the wire, and the refresh path + * re-mints only a coordinate that still resolves Live, so it will not resurrect this one. + */ + private suspend fun revoke( + dataDir: DataDir, + rest: Array, + ): Int { + val args = Args(rest) + val handle = args.positional(0, "community") + val link = args.positional(1, "token|url") + args.rejectUnknown() + + // Accept either the shareable URL (what a creator actually has to hand) or the bare token. + val token = + ConcordActions + .parseInviteLink(link) + ?.fragment + ?.token + ?.toHexKey() ?: link.lowercase() + if (!TOKEN_HEX.matches(token)) { + return Output.error("bad_args", "expected an invite URL or a 32-hex-character link token, got '$link'").let { 2 } + } + + val sc = ConcordStore(dataDir.concordFile).find(handle) ?: return notFound(handle) + Context.open(dataDir).use { ctx -> + ctx.prepare() + + val list = + readInviteList(ctx) + ?: return Output.error("invite_list_unreadable", "could not read your invite list (kind 13303), so the link signer needed to revoke is unknown — refusing to guess") + + val entry = list.entries.firstOrNull { it.token == token } + if (entry == null) { + return if (list.tombstones.any { it.token == token }) { + Output.error("already_revoked", "this link was already revoked; its signer_sk is gone from the list, so there is nothing left to re-publish") + } else { + Output.error("not_found", "no link with token $token in your invite list — only the account that minted a link can revoke it") + } + } + if (entry.communityId != sc.communityId) { + return Output.error("wrong_community", "that link belongs to community ${entry.communityId}, not '$handle' (${sc.communityId})") + } + + val tombstone = ConcordActions.revokeBundleAt(entry.signerSk.hexToByteArray(), TimeUtils.now()) + val ack = ctx.publish(tombstone, relaysFor(ctx, sc)) + RawEventSupport.publishGuard(ack, tombstone.id)?.let { return it } + + val recorded = + publishInviteList( + ctx, + ConcordInviteListDocument(tombstones = listOf(ConcordInviteListTombstone(token = token, communityId = sc.communityId))), + ) + if (!recorded) { + System.err.println( + "[concord] the link is revoked on the wire but the tombstone could not be recorded in your invite list (kind 13303); re-run this command once your outbox relays are reachable", + ) + } + + Output.emit( + mapOf( + "revoked" to true, + "token" to token, + "community_id" to sc.communityId, + "link_signer" to entry.signerPubKeyHex(), + "tombstone_event_id" to tombstone.id, + "tombstoned_in_list" to recorded, + ) + RawEventSupport.ackFields(ack), + ) + return 0 + } + } + + /** A link token is 16 bytes on the wire, so 32 hex characters once stored in the list. */ + private val TOKEN_HEX = Regex("^[0-9a-f]{32}$") + private suspend fun join( dataDir: DataDir, rest: Array, @@ -268,9 +412,47 @@ object ConcordCommands { ctx.prepare() val relays = (normalize(parsed.fragment.relays) + ctx.bootstrapRelays()) val wraps = ctx.drain(relays.associateWith { listOf(ConcordActions.bundleFilter(parsed.linkSignerPubKey)) }).map { it.second } + // Resolve the coordinate per CORD-05 §2 rather than opening whatever happens to decrypt: + // the newest event wins, so a vsk=9 tombstone retires the link even when a stale but + // still-openable copy is also present. Opening the first wrap that decrypts would let a + // relay that kept the old version hand out a link its creator revoked — and it cannot + // tell the user which of "revoked", "expired" or "gone" they are looking at. val bundle = - wraps.firstNotNullOfOrNull { ConcordActions.openBundle(it, parsed.fragment.token) } - ?: return Output.error("not_found", "no valid bundle for this link").let { 1 } + when (val status = ConcordActions.classifyInvite(wraps, parsed.fragment.token)) { + is InviteBundleStatus.Live -> status.invite + is InviteBundleStatus.Expired -> return Output.error("expired", "this invite link has expired and can no longer be joined") + InviteBundleStatus.Revoked -> return Output.error("revoked", "this invite link was revoked by its creator") + InviteBundleStatus.Unreadable -> return Output.error("incompatible", "something is published at this link's coordinate, but it is not a bundle this client can open") + InviteBundleStatus.Absent -> return Output.error("not_found", "no bundle for this link on any of its relays") + } + + // Refuse a link that readmits us after we were removed. A Refounding re-mints every + // outstanding link onto the new root (CORD-05), and an ex-member keeps the URL and its + // unlock token forever — so without this check the rotation that was supposed to expel + // them hands them the new keys instead. `recover` has always been ban-gated; `join` is + // the other door into the same room. + // + // Fails CLOSED on an unreadable plane: no verdict, no join. The banlist is only knowable + // after the bundle yields the root, which is why the check lives here rather than before. + val joinKeys = + ConcordActions.controlPlaneKeys( + communityRoot = bundle.communityRoot.hexToByteArray(), + communityId = bundle.communityId.hexToByteArray(), + rootEpoch = bundle.rootEpoch, + controlPk = bundle.controlPk, + ) + val joinRelays = normalize(bundle.relays).ifEmpty { relays } + val joinEditions = + ConcordActions.controlEditions( + ctx.drain(joinRelays.associateWith { listOf(ConcordActions.planeFilter(joinKeys.address)) }, pendingOnAuthRequired = true).map { it.second }, + joinKeys, + ) + if (joinEditions.isEmpty()) { + return Output.error("control_plane_unreadable", "could not fold this community's Control Plane, so whether it has banned you is unknown — refusing to join") + } + if (AuthorityResolver.resolve(joinEditions, bundle.owner).isBanned(ctx.signer.pubKey)) { + return Output.error("banned", "this community has banned this account; the link works but the roster does not admit you (CORD-04)") + } ConcordStore(dataDir.concordFile).upsert( StoredCommunity( @@ -284,6 +466,9 @@ object ConcordCommands { // community is still pre-split and folds at the legacy address. controlPk = bundle.controlPk ?: "", relays = bundle.relays, + // The stranded-recovery anchor: if a later Refounding leaves us out, re-resolving + // this link is the only way back (CORD-05/06). Stored bare, domain-agnostic. + inviteRef = ConcordActions.bareInviteRef(url) ?: "", ), ) Output.emit(mapOf("community_id" to bundle.communityId, "name" to bundle.name, "relays" to bundle.relays)) @@ -316,6 +501,267 @@ object ConcordCommands { controlRoot = sc.controlRoot.ifBlank { null }, ) + /** + * Adopts the `control_root` a staff-making Grant delivered to us (CORD-04 §3), persisting it to + * the local store and returning the now-writable keys — or null when nothing was delivered. + * + * The decision itself is [ConcordReceive.deliveredControlRoot], shared with Amethyst: it fails + * closed unless our own fold seats us as staff, the wrap opens under the granter↔member pairwise + * key, it names this epoch, and the secret derives to exactly the `control_pk` we already hold. + * + * Local-only on purpose: Amethyst republishes the kind-13302 list on adoption so a user's other + * devices follow, and doing that here would need amy to rebuild and sign the whole list. A CLI + * adoption therefore unblocks *this* account's writes; other devices adopt from their own fold. + */ + suspend fun adoptDeliveredControlRoot( + ctx: Context, + dataDir: DataDir, + sc: StoredCommunity, + editions: List, + ): Pair? { + val entry = entryFor(sc) + val authority = AuthorityResolver.resolve(editions, sc.owner) + val delivered = ConcordReceive.deliveredControlRoot(entry, editions, authority, ctx.signer) ?: return null + val updated = sc.copy(controlRoot = delivered) + ConcordStore(dataDir.concordFile).upsert(updated) + return updated to controlPlaneKeysFor(updated) + } + + /** The quartz list entry a [StoredCommunity] describes — the shape every commons helper takes. */ + fun entryFor(sc: StoredCommunity) = + ConcordCommunityListEntry( + id = sc.communityId, + owner = sc.owner, + ownerSalt = sc.ownerSalt, + root = sc.root, + rootEpoch = sc.rootEpoch, + controlPk = sc.controlPk.ifBlank { null }, + controlRoot = sc.controlRoot.ifBlank { null }, + heldRoots = sc.heldRoots.map { HeldRoot(it.epoch, it.root, it.controlPk.ifBlank { null }, it.controlRoot.ifBlank { null }) }, + relays = sc.relays, + name = sc.name, + inviteRef = sc.inviteRef.ifBlank { null }, + ) + + /** Folds [entry] back into the stored shape after a rotation is adopted. */ + fun storedFrom( + sc: StoredCommunity, + entry: ConcordCommunityListEntry, + ) = sc.copy( + root = entry.root, + rootEpoch = entry.rootEpoch, + controlPk = entry.controlPk ?: "", + controlRoot = entry.controlRoot ?: "", + heldRoots = entry.heldRoots.map { StoredHeldRoot(it.epoch, it.key, it.controlPk ?: "", it.controlRoot ?: "") }, + relays = entry.relays, + name = entry.name.ifBlank { sc.name }, + inviteRef = entry.inviteRef ?: sc.inviteRef, + ) + + /** + * `concord recover [COMMUNITY]` — the stranded-recovery receive path (CORD-05/06 A2). + * + * A Refounding carries only `(newRoot, newEpoch, rotator)` and **no recipient list**, so a + * member simply left out of the rekey receives nothing and sits on the dead epoch forever while + * everyone else moves on. There is no message to miss, which is why the rekey drain cannot help. + * The way back is the invite link the membership was joined through: the community keeps + * re-minting its bundle at the same addressable coordinate, so a live bundle at a **strictly + * higher** epoch than ours proves we were left behind — and carries the new root. + * + * Amethyst sweeps this on a timer; amy makes it an explicit verb, so it stays deterministic and + * scriptable rather than a background loop. + * + * The ban gate is the point of care. A removed member keeps the link's unlock token forever, so + * without it this walks them straight back into the epoch they were rotated out of. It reads the + * banlist of the epoch we are **leaving** (the last Control Plane we can still fold) and **fails + * closed**: a community whose plane will not fold yields no verdict and is skipped, never + * recovered. + */ + private suspend fun recover( + dataDir: DataDir, + rest: Array, + ): Int { + val args = Args(rest) + val handle = args.positionalOrNull(0) + args.rejectUnknown() + val store = ConcordStore(dataDir.concordFile) + val targets = + if (handle != null) { + listOf(store.find(handle) ?: return notFound(handle)) + } else { + store.load() + } + + Context.open(dataDir).use { ctx -> + ctx.prepare() + val results = mutableListOf>() + for (sc in targets) { + val inviteRef = sc.inviteRef.ifBlank { null } + if (inviteRef == null) { + results += mapOf("community_id" to sc.communityId, "name" to sc.name, "recovered" to false, "reason" to "no_invite_ref") + continue + } + val parsed = ConcordActions.parseInviteLink(inviteRef) + if (parsed == null) { + results += mapOf("community_id" to sc.communityId, "name" to sc.name, "recovered" to false, "reason" to "bad_invite_ref") + continue + } + val relays = (normalize(parsed.fragment.relays) + normalize(sc.relays)).ifEmpty { ctx.outboxRelays() } + val wraps = ctx.drain(relays.associateWith { listOf(ConcordActions.bundleFilter(parsed.linkSignerPubKey)) }).map { it.second } + // Only a LIVE bundle recovers: an expired or revoked link is not a rotation we missed. + val bundle = (ConcordActions.classifyInvite(wraps, parsed.fragment.token) as? InviteBundleStatus.Live)?.invite + if (bundle == null) { + results += mapOf("community_id" to sc.communityId, "name" to sc.name, "recovered" to false, "reason" to "no_live_bundle") + continue + } + + // Fold the epoch we are leaving to learn whether it banned us. No fold, no verdict, + // no recovery — the gate fails closed rather than assuming "not banned". + val cp = controlPlaneKeysFor(sc) + ctx.registerConcordStreamKeys(relays, listOfNotNull(cp.signer?.secretKey)) + val controlWraps = ctx.drain(relays.associateWith { listOf(ConcordActions.planeFilter(cp.address)) }, pendingOnAuthRequired = true).map { it.second } + val editions = ConcordActions.controlEditions(controlWraps, cp) + if (editions.isEmpty()) { + results += mapOf("community_id" to sc.communityId, "name" to sc.name, "recovered" to false, "reason" to "control_plane_not_folded") + continue + } + val bannedHere = AuthorityResolver.resolve(editions, sc.owner).isBanned(ctx.signer.pubKey) + + val merged = ConcordActions.recoverStranded(entryFor(sc), bundle, bannedHere) + if (merged == null) { + results += + mapOf( + "community_id" to sc.communityId, + "name" to sc.name, + "recovered" to false, + "reason" to if (bannedHere) "banned" else "already_current", + "root_epoch" to sc.rootEpoch, + ) + continue + } + store.upsert(storedFrom(sc, merged)) + results += + mapOf( + "community_id" to sc.communityId, + "name" to sc.name, + "recovered" to true, + "from_epoch" to sc.rootEpoch, + "root_epoch" to merged.rootEpoch, + ) + } + Output.emit(mapOf("communities" to results)) + return 0 + } + } + + /** + * `concord rekey [COMMUNITY]` — follow a Refounding we WERE re-keyed for (CORD-06). + * + * The normal counterpart to [recover]: a retained member gets a per-recipient blob on the next + * epoch's base-rekey plane, and opening it yields the new root. Amethyst drains this on its + * revision tick; amy has no tick, so it is a verb. Without it a Refounding launched from the CLI + * strands every other CLI member even though their blob is sitting on the relay. + * + * The rotator is authorized against the roster of the epoch being **left** — `hasPermission`, + * never `effectivePermissions`, so a banned BAN-holder cannot rotate us (CORD-06). Fails closed: + * a plane that will not fold yields no verdict and the community is skipped. + */ + private suspend fun rekey( + dataDir: DataDir, + rest: Array, + ): Int { + val args = Args(rest) + val handle = args.positionalOrNull(0) + args.rejectUnknown() + val store = ConcordStore(dataDir.concordFile) + val targets = if (handle != null) listOf(store.find(handle) ?: return notFound(handle)) else store.load() + + Context.open(dataDir).use { ctx -> + ctx.prepare() + val results = mutableListOf>() + for (sc in targets) { + val relays = relaysFor(ctx, sc) + val baseRekey = ConcordActions.nextBaseRekeyPlane(sc.root.hexToByteArray(), sc.communityId.hexToByteArray(), sc.rootEpoch) + ctx.registerConcordStreamKeys(relays, listOf(baseRekey.secretKey)) + val wraps = ctx.drain(relays.associateWith { listOf(ConcordActions.planeFilter(baseRekey.publicKeyHex)) }, pendingOnAuthRequired = true).map { it.second } + val received = + ConcordActions.openBaseRekey(wraps, baseRekey, ctx.signer, sc.communityId, sc.root.hexToByteArray(), sc.rootEpoch) + if (received == null) { + results += mapOf("community_id" to sc.communityId, "name" to sc.name, "rekeyed" to false, "reason" to "no_blob_for_us", "root_epoch" to sc.rootEpoch) + continue + } + if (received.newEpoch <= sc.rootEpoch) { + results += mapOf("community_id" to sc.communityId, "name" to sc.name, "rekeyed" to false, "reason" to "already_current", "root_epoch" to sc.rootEpoch) + continue + } + // Authorize the rotator against the epoch we are LEAVING — the last plane we can fold. + val cp = controlPlaneKeysFor(sc) + ctx.registerConcordStreamKeys(relays, listOfNotNull(cp.signer?.secretKey)) + val controlWraps = ctx.drain(relays.associateWith { listOf(ConcordActions.planeFilter(cp.address)) }, pendingOnAuthRequired = true).map { it.second } + val editions = ConcordActions.controlEditions(controlWraps, cp) + if (editions.isEmpty()) { + results += mapOf("community_id" to sc.communityId, "name" to sc.name, "rekeyed" to false, "reason" to "control_plane_not_folded") + continue + } + if (!ConcordReceive.isAuthorizedRotator(AuthorityResolver.resolve(editions, sc.owner), received.rotator)) { + results += mapOf("community_id" to sc.communityId, "name" to sc.name, "rekeyed" to false, "reason" to "unauthorized_rotator", "rotator" to received.rotator) + continue + } + val adopted = ConcordReceive.withAdoptedRoot(entryFor(sc), received.newRoot, received.newEpoch, received.newControlPk, received.newControlRoot) + store.upsert(storedFrom(sc, adopted)) + results += mapOf("community_id" to sc.communityId, "name" to sc.name, "rekeyed" to true, "from_epoch" to sc.rootEpoch, "root_epoch" to received.newEpoch, "rotator" to received.rotator) + } + Output.emit(mapOf("communities" to results)) + return 0 + } + } + + /** + * This account's CORD-05 Invite List (kind 13303) — the creator's private, self-encrypted record + * of every link they minted, so a rotation can refresh those links instead of orphaning them. + * Empty when none was ever published. + */ + suspend fun readInviteList(ctx: Context): ConcordInviteListDocument? { + val relays = ctx.outboxRelays() + if (relays.isEmpty()) return null + val filter = Filter(kinds = listOf(ConcordInviteListEvent.KIND), authors = listOf(ctx.signer.pubKey)) + // Terminal reasons, not just events: a drain returns nothing both when a relay served us and + // had nothing AND when nobody answered. Reading the second as "no list yet" is how the + // read-merge-write below wipes the signer_sk of every link it failed to read. + val reasons = mutableMapOf() + val newest = + ctx + .drain(relays.associateWith { listOf(filter) }, doneOut = reasons) + // Filter by kind BEFORE picking the newest — a stray event at this coordinate would + // otherwise make the list read as unreadable and refuse every later write. + .mapNotNull { it.second as? ConcordInviteListEvent } + .maxByOrNull { it.createdAt } + ?: return if (reasons.anyRelayServed()) ConcordInviteListDocument.EMPTY else null + return newest.decrypt(ctx.signer) + } + + /** + * Merges [patch] into the published list and republishes it, returning whether it landed. + * + * Read-merge-write, and **aborts rather than overwriting** when the read fails: kind 13303 is + * replaceable, so writing a patch-only document over a list we could not read deletes every + * other link's `signer_sk`. Those secrets cannot be regenerated, and losing one orphans its + * link at the next rotation, stranding everyone holding that URL. + * + * Account-scoped, like the coordinate itself — (13303, me, "") is one list for every community, + * so reading or writing it on a single community's relays would fork it. + */ + suspend fun publishInviteList( + ctx: Context, + patch: ConcordInviteListDocument, + ): Boolean { + val relays = ctx.outboxRelays() + if (relays.isEmpty()) return false + val base = readInviteList(ctx) ?: return false + val event = ConcordInviteListEvent.create(ctx.signer, ConcordInviteList.merge(base, patch), TimeUtils.now()) + return ctx.publish(event, relays).values.any { it.accepted } + } + fun notFound(handle: String): Int { Output.error("not_found", "no joined community matching '$handle' — run `amy concord list`") return 1 diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordModCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordModCommands.kt index 3633b24dbe..9b52786df6 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordModCommands.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordModCommands.kt @@ -28,11 +28,14 @@ import com.vitorpamplona.amethyst.cli.stores.ConcordStore import com.vitorpamplona.amethyst.cli.stores.StoredCommunity import com.vitorpamplona.amethyst.commons.actions.ConcordActions import com.vitorpamplona.amethyst.commons.actions.ConcordModeration +import com.vitorpamplona.amethyst.commons.actions.ConcordReceive import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityState import com.vitorpamplona.quartz.concord.cord04Roles.ConcordPermissions import com.vitorpamplona.quartz.concord.cord04Roles.ControlEdition import com.vitorpamplona.quartz.concord.cord04Roles.RoleEntity +import com.vitorpamplona.quartz.concord.cord05Invites.InviteBundleStatus import com.vitorpamplona.quartz.concord.crypto.ControlPlaneKeys +import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray import com.vitorpamplona.quartz.nip01Core.core.toHexKey import com.vitorpamplona.quartz.utils.RandomInstance @@ -51,7 +54,7 @@ object ConcordModCommands { val sc = ConcordStore(dataDir.concordFile).find(handle) ?: return ConcordCommands.notFound(handle) Context.open(dataDir).use { ctx -> ctx.prepare() - val (_, editions) = load(ctx, sc) + val (_, editions) = load(ctx, sc, dataDir) val state = ConcordCommunityState.fold(editions, sc.owner) Output.emit( mapOf( @@ -92,7 +95,7 @@ object ConcordModCommands { Context.open(dataDir).use { ctx -> ctx.prepare() - val (cp, editions) = load(ctx, sc) + val (cp, editions) = load(ctx, sc, dataDir) writeGuard(cp)?.let { return it } val roleId = RandomInstance.bytes(32) val role = RoleEntity(name = name, position = position, permissions = ConcordPermissions.of(*permBits.toIntArray()).toWire()) @@ -119,7 +122,8 @@ object ConcordModCommands { Context.open(dataDir).use { ctx -> ctx.prepare() val member = ctx.requireUserHex(userRef) - val (cp, editions) = load(ctx, sc) + val loaded = load(ctx, sc, dataDir) + val (cp, editions) = loaded writeGuard(cp)?.let { return it } // A Grant that first makes its member staff must carry the write secret in the same // edition (CORD-04 §3); ConcordModeration wraps it pairwise when the granted roles @@ -134,7 +138,10 @@ object ConcordModCommands { current = editions, createdAt = TimeUtils.now(), owner = sc.owner, - controlRoot = sc.controlRoot.ifBlank { null }?.hexToByteArray(), + controlRoot = + loaded.community.controlRoot + .ifBlank { null } + ?.hexToByteArray(), epoch = sc.rootEpoch, ) val ack = ctx.publish(wrap, ConcordCommands.relaysFor(ctx, sc)) @@ -170,7 +177,7 @@ object ConcordModCommands { Context.open(dataDir).use { ctx -> ctx.prepare() val member = ctx.requireUserHex(userRef) - val (cp, editions) = load(ctx, sc) + val (cp, editions) = load(ctx, sc, dataDir) writeGuard(cp)?.let { return it } val cid = sc.communityId.hexToByteArray() val wrap = @@ -186,11 +193,283 @@ object ConcordModCommands { } } + /** + * The drained Control Plane: the community as stored *after* any adoption, its keys, and the + * editions to chain onto. [community] matters because adopting a delivered `control_root` + * rewrites the stored record — a caller that kept the pre-load copy would then fail to pass the + * secret on in its own Grant (CORD-04 §3). + */ + private class LoadedControl( + val community: StoredCommunity, + val keys: ControlPlaneKeys, + val editions: List, + ) { + operator fun component1() = keys + + operator fun component2() = editions + } + + /** + * `concord refound COMMUNITY --remove USER[,USER…]` — a CORD-06 Refounding: the hard removal. + * + * A ban only strips standing; the removed member keeps every key they ever held, so the room is + * only truly closed to them by rotating the `community_root` (and, since CORD-02 §2, a fresh + * `control_root` beside it, so a demoted staffer's retained secret dies with the epoch). The + * compacted Control Plane is re-sealed at the new epoch and each retained member gets a rekey + * blob; nobody else can follow. + * + * Authority mirrors Amethyst exactly: `hasPermission`, never `effectivePermissions`, so a banned + * BAN-holder cannot launch one; the owner is never a valid target; and removal takes the same + * rank rule as a ban (CORD-04 §3) — an admin cannot Refound a peer admin out. + * + * **The recipient set is a floor, not a census.** It is the roster ∪ Guestbook ∪ the authors of + * every channel message we can decrypt ∪ ourselves, minus the removed and already-banned — the + * same union Amethyst builds, because a member who only ever posted holds no role and leaves no + * Guestbook motion, and omitting them silently expels them. A member with no trace at all still + * cannot be re-keyed; `concord recover` is how they get back. + */ + suspend fun refound( + dataDir: DataDir, + rest: Array, + ): Int { + val args = Args(rest) + val handle = args.positional(0, "community") + val removeArg = args.flag("remove") ?: return Output.error("bad_args", "refound --remove USER[,USER…]").let { 2 } + args.rejectUnknown() + val sc = ConcordStore(dataDir.concordFile).find(handle) ?: return ConcordCommands.notFound(handle) + + Context.open(dataDir).use { ctx -> + ctx.prepare() + val removed = + removeArg + .split(',') + .map { it.trim() } + .filter { it.isNotEmpty() } + .map { ctx.requireUserHex(it).lowercase() } + .toSet() + if (removed.isEmpty()) return Output.error("bad_args", "--remove needs at least one user") + + val loaded = load(ctx, sc, dataDir) + val (cp, editions) = loaded + val state = ConcordCommunityState.fold(editions, sc.owner) + val authority = state.authority + val me = ctx.signer.pubKey + + if (!ConcordReceive.isAuthorizedRotator(authority, me)) { + return Output.error("forbidden", "this account cannot refound: a Refounding takes BAN (or ownership), and a banned holder is refused (CORD-06)") + } + if (removed.any { authority.isOwner(it) }) { + return Output.error("forbidden", "the owner is never a valid removal target (CORD-04 §3)") + } + // An admin cannot Refound a peer admin out any more than they could ban one. + if (!authority.isOwner(me) && removed.any { !authority.canActOn(me, it, ConcordPermissions.BAN) }) { + return Output.error("forbidden", "you do not outrank every member you are removing (CORD-04 §3, equal cannot act on equal)") + } + // A Refounding writes the current plane (the pre-rotation bans) and the new one, so on a + // split epoch it takes the current control_root (CORD-02 §2). + writeGuard(cp)?.let { return it } + + val relays = ConcordCommands.relaysFor(ctx, sc) + + // 1. Ban the removed on the CURRENT plane, so the compacted snapshot — and therefore the + // new epoch — carries the ban. Each edition chains onto the updated banlist head. + var chain = editions + val banWraps = mutableListOf() + for (target in removed) { + val banWrap = ConcordModeration.ban(ctx.signer, cp, sc.communityId.hexToByteArray(), target, chain, TimeUtils.now(), owner = sc.owner) + val ack = ctx.publish(banWrap, relays) + if (ack.values.none { it.accepted }) { + return Output.error("ban_not_published", "the pre-rotation ban for $target was not accepted by any relay; refusing to refound with a banlist that would not survive") + } + banWraps += banWrap + chain = chain + (ConcordActions.controlEditions(listOf(banWrap), cp)) + } + + // 2. Everyone we are keeping. See the note above on why this reaches past the roster. + val candidates = + (rosterOf(authority) + guestbookMembersOf(ctx, sc) + channelAuthorsOf(ctx, sc, state) + me) + .mapTo(HashSet()) { it.lowercase() } + .apply { + removeAll(removed) + removeAll(authority.bannedMembers().map { it.lowercase() }.toSet()) + } + val recipients = boundRecipients(candidates, authority) + + // 3. Build: new root + fresh control_root, compacted plane, per-recipient blobs (staff + // get the 136-byte form carrying the secret, everyone else the 104-byte pubkey one). + val newRoot = RandomInstance.bytes(32) + val newControlRoot = RandomInstance.bytes(32) + // Compact from what we KNOW the plane holds: the wraps we drained plus the bans we just + // published. Re-draining alone would race the relay's indexing, and a relay that has not + // yet echoed the ban back (or that ACKed and stored nothing) would produce a new epoch + // whose roster never banned the member we are removing. + val drained = ctx.drain(relays.associateWith { listOf(ConcordActions.planeFilter(cp.address)) }, pendingOnAuthRequired = true).map { it.second } + val controlWraps = (drained + banWraps).distinctBy { it.id } + val build = + ConcordActions.buildRefounding( + rotatorSigner = ctx.signer, + communityId = sc.communityId, + priorRoot = sc.root.hexToByteArray(), + newRoot = newRoot, + newControlRoot = newControlRoot, + rootEpoch = sc.rootEpoch, + priorControlWraps = controlWraps, + priorControlKeys = cp, + recipientsXOnly = recipients, + staffXOnly = authority.staffMembers(), + createdAt = TimeUtils.now(), + ownerPubKey = sc.owner, + ) + + // 4. The compacted plane (the new epoch's state) then the blobs (the key that opens it). + build.controlWraps.forEach { ctx.publish(it, relays) } + build.rekeyWraps.forEach { ctx.publish(it, relays) } + + // 5. Adopt the new epoch ourselves — the same pure rewrite Amethyst uses, banking the + // epoch we are leaving for the anti-rollback floor. + val adopted = + ConcordReceive.withAdoptedRoot( + ConcordCommands.entryFor(loaded.community), + newRoot, + build.newEpoch, + build.newControlKeys.address.hexToByteArray(), + newControlRoot, + ) + val stored = ConcordCommands.storedFrom(loaded.community, adopted) + ConcordStore(dataDir.concordFile).upsert(stored) + + // 6. Refresh every link we minted, at its OWN coordinate, so it now resolves to the new + // epoch. This is the liveness half of stranded recovery (A2): a member this Refounding + // left out has no rekey blob and no message to miss, so re-resolving their link is the + // only way back — and it only works if the bundle moves with the community instead of + // being orphaned at a dead epoch. Minting a fresh link would not help them; the link + // they hold is the one that must move. + // + // Safe for every link because recovery is ban-gated at the epoch being left, and step 1 + // banned everyone being removed — so a removed member's own `recover` is refused even + // though their link now resolves. + val now = TimeUtils.now() + var refreshed = 0 + val list = ConcordCommands.readInviteList(ctx) + val tombstoned = list?.tombstones?.mapTo(HashSet()) { it.token } ?: emptySet() + for (link in list?.entries.orEmpty()) { + if (link.communityId != stored.communityId) continue + // An elapsed or retired link can no longer be joined, so re-posting it would only + // resurrect a dead URL at a live epoch (CORD-05). + if (link.isExpired(now) || link.token in tombstoned) continue + runCatching { + val token = link.token.hexToByteArray() + // Refresh from the link's CURRENT bundle so its own fields — expiry, channel + // grants, icon, label — survive the rotation, and so a coordinate whose newest + // event is a revocation tombstone is left revoked instead of being re-opened. + val wraps = ctx.drain(relays.associateWith { listOf(ConcordActions.bundleFilter(link.signerPubKeyHex())) }).map { it.second } + val live = ConcordActions.classifyInvite(wraps, token) as? InviteBundleStatus.Live ?: return@runCatching + val moved = + live.invite.copy( + communityRoot = stored.root, + rootEpoch = stored.rootEpoch, + controlPk = stored.controlPk.ifBlank { null }, + relays = stored.relays, + ) + ctx.publish(ConcordActions.remintBundleAt(link.signerSk.hexToByteArray(), token, moved, now), relays) + refreshed++ + } + } + + Output.emit( + mapOf( + "community_id" to sc.communityId, + "removed" to removed.toList(), + "from_epoch" to sc.rootEpoch, + "root_epoch" to build.newEpoch, + "recipients" to recipients.size, + "control_wraps" to build.controlWraps.size, + "rekey_wraps" to build.rekeyWraps.size, + "invites_refreshed" to refreshed, + ), + ) + return 0 + } + } + + /** + * How many recipients one Refounding will re-key, mirroring Amethyst's own cap. + * + * Two thirds of the recipient union — Guestbook joins and observed channel authors — are + * attacker-writable: any key can announce a join or post once. Without a bound, padding those + * sets inflates the cost of the only hard removal Concord has until rotating becomes + * impractical, so the attack raises the price of its own remedy (B4 in the soft-ban audit). + */ + private const val MAX_REFOUNDING_RECIPIENTS = 5_000 + + /** + * Caps [candidates], keeping the members whose standing is owner-rooted and therefore cannot be + * padded from outside. Anything dropped is reported rather than silently truncated — a dropped + * member is stranded on the dead epoch and their only way back is `concord recover`. + */ + private fun boundRecipients( + candidates: Set, + authority: com.vitorpamplona.quartz.concord.cord04Roles.AuthorityResolver, + ): List { + if (candidates.size <= MAX_REFOUNDING_RECIPIENTS) return candidates.toList() + val vouched = (authority.roleHolders() + authority.staffMembers()).mapTo(HashSet()) { it.lowercase() } + val kept = LinkedHashSet() + candidates.filterTo(kept) { it in vouched } + for (candidate in candidates) { + if (kept.size >= MAX_REFOUNDING_RECIPIENTS) break + kept.add(candidate) + } + val dropped = candidates.size - kept.size + if (dropped > 0) { + System.err.println("[concord] refounding recipient set trimmed to ${kept.size} of ${candidates.size}: $dropped member(s) will be stranded on the prior epoch") + } + return kept.toList() + } + + /** Owner + everyone holding a role — owner-rooted, so it cannot be padded from outside. */ + private fun rosterOf(authority: com.vitorpamplona.quartz.concord.cord04Roles.AuthorityResolver): Set = (authority.roleHolders() + authority.staffMembers()).mapTo(HashSet()) { it.lowercase() } + + /** Live Guestbook membership at this epoch (joins minus later leaves, CORD-02 §5). */ + private suspend fun guestbookMembersOf( + ctx: Context, + sc: StoredCommunity, + ): Set = + runCatching { + val gb = ConcordActions.guestbookPlane(sc.root.hexToByteArray(), sc.communityId.hexToByteArray(), sc.rootEpoch) + val relays = ConcordCommands.relaysFor(ctx, sc) + ctx.registerConcordStreamKeys(relays, listOf(gb.secretKey)) + val wraps = ctx.drain(relays.associateWith { listOf(ConcordActions.planeFilter(gb.publicKeyHex)) }, pendingOnAuthRequired = true).map { it.second } + ConcordActions.guestbookMembers(wraps, gb).mapTo(HashSet()) { it.lowercase() } + }.getOrDefault(emptySet()) + + /** + * Authors of every channel message we can decrypt. Most members never send a Guestbook motion, + * so without this a Refounding silently expels everyone who had only ever posted. + */ + private suspend fun channelAuthorsOf( + ctx: Context, + sc: StoredCommunity, + state: ConcordCommunityState, + ): Set { + val out = HashSet() + val relays = ConcordCommands.relaysFor(ctx, sc) + for ((channelIdHex, _) in state.channels) { + runCatching { + val key = ConcordActions.publicChannel(sc.root.hexToByteArray(), channelIdHex.hexToByteArray(), sc.rootEpoch) + ctx.registerConcordStreamKeys(relays, listOf(key.secretKey)) + val wraps = ctx.drain(relays.associateWith { listOf(ConcordActions.planeFilter(key.publicKeyHex)) }, pendingOnAuthRequired = true).map { it.second } + ConcordActions.channelMessages(wraps, key, channelIdHex, sc.rootEpoch).mapTo(out) { it.author.lowercase() } + } + } + return out + } + /** Drain the control plane and return its keys + current editions to chain onto. */ private suspend fun load( ctx: Context, sc: StoredCommunity, - ): Pair> { + dataDir: DataDir? = null, + ): LoadedControl { val cp = ConcordCommands.controlPlaneKeysFor(sc) val relays = ConcordCommands.relaysFor(ctx, sc) // Concord relays serve the plane's kind-1059 only to a connection AUTHed as the stream @@ -198,7 +477,18 @@ object ConcordModCommands { // that secret is staff-only (CORD-02 §2), and a member simply has nothing to register. ctx.registerConcordStreamKeys(relays, listOfNotNull(cp.signer?.secretKey)) val wraps = ctx.drain(relays.associateWith { listOf(ConcordActions.planeFilter(cp.address)) }, pendingOnAuthRequired = true).map { it.second } - return cp to ConcordActions.controlEditions(wraps, cp) + val editions = ConcordActions.controlEditions(wraps, cp) + + // A promotion to staff delivers the Control Plane write key inside the Grant itself + // (CORD-04 §3), so the fold that seats the role is also when the key arrives. Amethyst + // drains this on its revision tick; amy has no tick, so the fold a command already does is + // the moment to adopt — otherwise a CLI-promoted staffer holds a rank it can never write + // under. Same shared, fail-closed check both clients use. + if (dataDir != null && !cp.canWrite) { + val adopted = ConcordCommands.adoptDeliveredControlRoot(ctx, dataDir, sc, editions) + if (adopted != null) return LoadedControl(adopted.first, adopted.second, ConcordActions.controlEditions(wraps, adopted.second)) + } + return LoadedControl(sc, cp, editions) } /** diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/stores/ConcordStore.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/stores/ConcordStore.kt index 7ae731a877..c36b1ac915 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/stores/ConcordStore.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/stores/ConcordStore.kt @@ -48,6 +48,11 @@ data class StoredCommunity( // Past access roots kept per epoch (CORD-06 Refounding rotates the root). Lets `read --epoch ` // re-derive a prior epoch's Chat Plane to reach pre-refounding history. Populated by `import`. val heldRoots: List = emptyList(), + // The bare `#` invite this membership was joined through — the stranded-recovery + // anchor (CORD-05/06). A Refounding carries no recipient list, so a member simply left out of the + // rekey has no message to miss: re-resolving this link is the only way back. Blank for a direct + // invite or a community joined before amy stored it. + val inviteRef: String = "", ) /** A past community_root for a specific epoch, mirroring quartz `HeldRoot`. */ @@ -56,6 +61,12 @@ data class StoredHeldRoot( val root: String = "", /** That epoch's Control Plane address; blank for a legacy, pre-split epoch (CORD-02 §5). */ val controlPk: String = "", + /** + * That epoch's staff write key, banked only if we held it. A relay that gates the prior epoch's + * Control Plane on NIP-42 AUTH as the stream key will not serve those wraps without it — and + * those wraps are what rebuild the anti-rollback floor. + */ + val controlRoot: String = "", ) /** diff --git a/commons/ARCHITECTURE.md b/commons/ARCHITECTURE.md index b86c122c6f..25c594b532 100644 --- a/commons/ARCHITECTURE.md +++ b/commons/ARCHITECTURE.md @@ -101,7 +101,7 @@ they are shared across the GUI apps. Treat as GUI-shared, not strictly headless. ### Relay client | Package | UI? | Purpose | |----------------|-----|---------| -| `relayClient` | no | Compose-scoped subscription managers, filter assemblers, EOSE managers, preloaders. (Despite a `composeSubscriptionManagers` subpackage name, this is subscription-lifecycle logic, not UI.) | +| `relayClient` | no | Compose-scoped subscription managers, filter assemblers, EOSE managers, preloaders. (Despite a `composeSubscriptionManagers` subpackage name, this is subscription-lifecycle logic, not UI.) The canonical **per-visible loading** entry points live here: `relayClient/user/` (`observeUser*` — kind-0 metadata) and `relayClient/event/` (`EventFinderFilterAssemblerSubscription`/`observeNote*` — reactions/zaps/reposts). See the `relay-client` skill. | | `relays` | no | Low-level EOSE/relay-timing bookkeeping (`EOSECache`, `EOSERelayList`). | ### Platform abstractions (`expect`/`actual`) diff --git a/commons/build.gradle.kts b/commons/build.gradle.kts index 2fccbea2ce..952b39c596 100644 --- a/commons/build.gradle.kts +++ b/commons/build.gradle.kts @@ -297,6 +297,10 @@ val verifyKmpPurity by tasks.registering { "Thread.sleep" to "use kotlinx.coroutines.delay or platform-specific actual", "java.util.UUID" to "use kotlin.uuid.Uuid", "kotlin.jvm.Synchronized" to "use KmpLock.withLock {}", + // The bare call, not just the annotation: `synchronized(lock) {}` resolves + // from kotlin-stdlib-jvm with no import, so it compiles on Android/JVM and + // only fails at the iOS compile step. Catch it here instead. + "synchronized(" to "`synchronized` is JVM-only — use KmpLock.withLock {}", "kotlin.jvm.Volatile" to "use kotlin.concurrent.Volatile", ) val offenders = diff --git a/commons/src/commonMain/composeResources/files/napplet/shell.html b/commons/src/commonMain/composeResources/files/napplet/shell.html index 78e42c56c3..e7658bad3e 100644 --- a/commons/src/commonMain/composeResources/files/napplet/shell.html +++ b/commons/src/commonMain/composeResources/files/napplet/shell.html @@ -1,13 +1,10 @@ @@ -19,7 +16,7 @@ - + diff --git a/commons/src/commonMain/composeResources/files/napplet/shim.js b/commons/src/commonMain/composeResources/files/napplet/shim.js index 4ac67e1cd1..f36c48280b 100644 --- a/commons/src/commonMain/composeResources/files/napplet/shim.js +++ b/commons/src/commonMain/composeResources/files/napplet/shim.js @@ -79,7 +79,7 @@ // Subscription pushes are keyed by subId, not a request id. if (msg.type === 'relay.event' || msg.type === 'relay.eose' || msg.type === 'relay.closed') { var sub = subs[msg.subId]; if (!sub) return; - if (msg.type === 'relay.event') { if (sub.onEvent) sub.onEvent(msg.event); } + if (msg.type === 'relay.event') { if (sub.onEvent) sub.onEvent(msg.result); } else if (msg.type === 'relay.eose') { if (sub.onEose) sub.onEose(); } else { delete subs[msg.subId]; if (sub.onClosed) sub.onClosed(msg.reason); } return; @@ -91,7 +91,7 @@ // identity.changed push: the active user's key changed (account switch / connect / disconnect). if (msg.type === 'identity.changed') { identityHandlers.slice().forEach(function(h){ try { h(msg.pubkey); } catch (_) {} }); return; } if (!msg.id) return; - var p = pending[msg.id]; if (!p) return; delete pending[msg.id]; + var p = pending[msg.id]; if (!p) return; delete pending[msg.id]; if (p.cleanup) p.cleanup(); if (msg.ok) p.resolve(msg); else { var err = new Error(msg.reason || msg.operation || msg.error || 'napplet error'); err.napplet = msg; p.reject(err); } } @@ -101,16 +101,31 @@ window.addEventListener('message', function(e){ if (e.source !== parent) return; onIncoming(e.data); }); } function field(promise, name){ return promise.then(function(m){ return m[name]; }); } - function normFilters(filters){ return Array.isArray(filters) ? { filters: filters } : { filter: filters || {} }; } + function resourceCall(type, fields, opts){ + var signal = opts && opts.signal; + if (signal && signal.aborted) return Promise.reject(new DOMException('Aborted', 'AbortError')); + return new Promise(function(resolve, reject){ + var env = { type: type }; for (var k in fields) env[k] = fields[k]; + var id = send(env), onAbort; + var cleanup = function(){ if (signal && onAbort) signal.removeEventListener('abort', onAbort); }; + pending[id] = { resolve: resolve, reject: reject, cleanup: cleanup }; + if (signal) { + onAbort = function(){ + if (!pending[id]) return; + delete pending[id]; cleanup(); + post('resource.cancel', { id: id }); + reject(new DOMException('Aborted', 'AbortError')); + }; + signal.addEventListener('abort', onAbort, { once: true }); + } + }); + } + function normFilters(filters){ return { filters: Array.isArray(filters) ? filters : [filters || {}] }; } function bytesToB64(bytes){ var u = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes); var s=''; for (var i=0;i= 0) identityHandlers.splice(i, 1); - if (identityHandlers.length === 0) post('identity.unwatch'); } }; } }, @@ -152,6 +164,7 @@ var subId = 's' + (seq++); subs[subId] = { onEvent: onEvent, onEose: onEose }; var env = normFilters(filters); env.subId = subId; + if (options && options.relay) env.relay = options.relay; post('relay.subscribe', env); return { close: function(){ delete subs[subId]; post('relay.close', { subId: subId }); } }; } @@ -161,23 +174,49 @@ getItem: function(key){ return field(call('storage.get', { key: key }), 'value'); }, setItem: function(key, value){ return call('storage.set', { key: key, value: value }).then(function(){}); }, removeItem: function(key){ return call('storage.remove', { key: key }).then(function(){}); }, - keys: function(){ return field(call('storage.keys'), 'keys'); } + keys: function(){ return field(call('storage.keys'), 'keys'); }, + instance: { + getItem: function(key){ return field(call('storage.get', { key: key, scope: 'instance' }), 'value'); }, + setItem: function(key, value){ return call('storage.set', { key: key, value: value, scope: 'instance' }).then(function(){}); }, + removeItem: function(key){ return call('storage.remove', { key: key, scope: 'instance' }).then(function(){}); }, + keys: function(){ return field(call('storage.keys', { scope: 'instance' }), 'keys'); } + } }, // value.payInvoice is an Amethyst-specific extension (not part of @napplet/shim). value: { payInvoice: function(invoice){ return field(call('value.payInvoice', { invoice: invoice }), 'preimage'); } }, resource: { - // The shell rebuilds the Blob from the host's base64 before this resolves. - bytes: function(url){ return field(call('resource.bytes', { url: url }), 'blob'); }, - bytesAsObjectURL: function(url){ return field(call('resource.bytes', { url: url }), 'blob').then(function(blob){ return URL.createObjectURL(blob); }); } + info: function(){ return field(call('resource.info'), 'info'); }, + // The shell rebuilds Blobs from the host's base64 before these resolve. + bytes: function(url, opts){ return field(resourceCall('resource.bytes', { url: url }, opts), 'blob'); }, + bytesMany: function(urls, opts){ return field(resourceCall('resource.bytesMany', { urls: Array.from(urls || []) }, opts), 'items'); }, + bytesAsObjectURL: function(url){ + var objectUrl = '', revoked = false; + var handle = { url: '', revoke: function(){ if (revoked) return; revoked = true; if (objectUrl) URL.revokeObjectURL(objectUrl); } }; + var ready = available.resource.bytes(url).then(function(blob){ + if (revoked) return; + objectUrl = URL.createObjectURL(blob); handle.url = objectUrl; return objectUrl; + }); + Object.defineProperty(handle, 'ready', { value: ready, enumerable: false }); + return handle; + } }, upload: { // Sends the SDK's upload.upload; we inline the bytes as base64 (shell.html does the same for // a Blob from a stock napplet). Resolves to the uploaded URL. blob: function(bytes, contentType){ return field(call('upload.upload', { request: { dataBase64: bytesToB64(bytes), mimeType: contentType } }), 'url'); } + }, + theme: { + get: function(){ return field(call('theme.get'), 'theme'); } } }; + var requested = []; + try { if (Array.isArray(window.__nappletDomains)) requested = window.__nappletDomains; } catch (_) {} + var napplet = {}; + requested.forEach(function(domain){ + if (typeof domain === 'string' && Object.prototype.hasOwnProperty.call(available, domain)) napplet[domain] = available[domain]; + }); window.napplet = Object.freeze(napplet); // ---- IME agent (in-app browser only) ------------------------------------------------------- diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordActions.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordActions.kt index d99679263f..e11a4278fa 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordActions.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordActions.kt @@ -201,6 +201,16 @@ object ConcordActions { /** The public invite bundle for a link signer. */ fun bundleFilter(linkSignerPubKeyHex: HexKey): Filter = Filter(kinds = listOf(ConcordInviteBundleEvent.KIND), authors = listOf(linkSignerPubKeyHex)) + /** + * The bundles of several links at once — one REQ over every link signer instead of a round trip + * per link, which is what a Refounding needs when it re-mints a creator's whole set. + * + * Partition the result by `pubKey` before classifying: [ConcordInviteBundle.classify] resolves a + * single coordinate, so handing it a pooled set would let one link's revocation tombstone decide + * another link's status purely by being newer. + */ + fun bundlesFilter(linkSignerPubKeyHexes: List): Filter = Filter(kinds = listOf(ConcordInviteBundleEvent.KIND), authors = linkSignerPubKeyHexes) + /** Pending direct invites addressed to the given member (indexed by k=3313). */ fun directInvitesFilter(memberPubKeyHex: HexKey): Filter = Filter(kinds = listOf(ConcordStreamEnvelope.KIND_WRAP), tags = mapOf("p" to listOf(memberPubKeyHex), "k" to listOf(ConcordDirectInvite.KIND.toString()))) @@ -436,6 +446,44 @@ object ConcordActions { relays: List? = null, ): MintedInviteLink = ConcordInviteBundle.mintLink(base, invite, createdAt, relays) + /** + * Re-publishes a bundle at an **existing** link's coordinate, carrying [invite] refreshed for the + * current epoch (CORD-05 §1). The kind-33301 bundle is addressable and authored by the link + * signer, so re-signing with the same [linkSignerPrivKey] and re-encrypting under the same + * [token] replaces what is there — every holder of that link keeps working, now pointing at the + * new root. + * + * This is what makes stranded recovery live: a member a Refounding left out has no rekey blob and + * no message to miss, and re-resolving their link is the only way back — which requires the + * community to re-mint at the *same* coordinate rather than issuing a fresh link. Minting a new + * link leaves the old one pointing at a dead epoch forever. + * + * Safe to call for every live link because recovery is ban-gated at the epoch being left + * (CORD-06, A2): a member the Refounding removed was banned on the way out, so their own + * `recover` is refused even though their link now resolves. + */ + fun remintBundleAt( + linkSignerPrivKey: ByteArray, + token: ByteArray, + invite: CommunityInvite, + createdAt: Long, + ): Event = ConcordInviteBundle.build(linkSignerPrivKey, token, invite, createdAt) + + /** + * Retires an existing link by publishing a `vsk=9` revocation tombstone at its coordinate + * (CORD-05 §2). Once this lands, every client resolving that URL gets + * [com.vitorpamplona.quartz.concord.cord05Invites.InviteBundleStatus.Revoked] instead of keys. + * + * Publish this *before* recording the tombstone in the kind-13303 Invite List — the list entry + * carries the only copy of the `signer_sk` this call needs, and the list merge drops a + * tombstoned token's entry for good. Recording first and failing to publish would leave the link + * live on the wire with no way left to retire it. + */ + fun revokeBundleAt( + linkSignerPrivKey: ByteArray, + createdAt: Long, + ): Event = ConcordInviteBundle.buildRevocation(linkSignerPrivKey, createdAt) + /** Parses a shareable invite URL into its pointer + private fragment. */ fun parseInviteLink(url: String): ParsedInviteLink? = ConcordInviteLink.parseUrl(url) @@ -454,7 +502,8 @@ object ConcordActions { fun recoverStranded( entry: ConcordCommunityListEntry, bundle: CommunityInvite, - ): ConcordCommunityListEntry? = ConcordStrandedRecovery.mergeForward(entry, bundle) + bannedAtCurrentEpoch: Boolean, + ): ConcordCommunityListEntry? = ConcordStrandedRecovery.mergeForward(entry, bundle, bannedAtCurrentEpoch) /** Decrypts + validates a fetched bundle event with the link token; null if invalid. */ fun openBundle( @@ -544,6 +593,7 @@ object ConcordActions { recipientsXOnly: List, staffXOnly: Set, createdAt: Long, + ownerPubKey: HexKey, ): RefoundingBuild = ConcordRefounding.build( rotatorSigner = rotatorSigner, @@ -557,6 +607,7 @@ object ConcordActions { recipientsXOnly = recipientsXOnly, staffXOnly = staffXOnly, createdAt = createdAt, + ownerPubKey = ownerPubKey, ) /** diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordReceive.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordReceive.kt new file mode 100644 index 0000000000..b807ae24de --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordReceive.kt @@ -0,0 +1,145 @@ +/* + * 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.actions + +import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEntry +import com.vitorpamplona.quartz.concord.cord02Community.HeldRoot +import com.vitorpamplona.quartz.concord.cord04Roles.AuthorityResolver +import com.vitorpamplona.quartz.concord.cord04Roles.ConcordJson +import com.vitorpamplona.quartz.concord.cord04Roles.ConcordPermissions +import com.vitorpamplona.quartz.concord.cord04Roles.ControlEdition +import com.vitorpamplona.quartz.concord.cord04Roles.ControlEntityKind +import com.vitorpamplona.quartz.concord.cord04Roles.ControlRootWrap +import com.vitorpamplona.quartz.concord.cord04Roles.GrantEntity +import com.vitorpamplona.quartz.concord.crypto.ConcordKeyDerivation +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner + +/** + * The **receive** half of Concord's key lifecycle, as pure functions: what a client must adopt + * when a Grant hands it the Control Plane write key (CORD-04 §3), and how an entry is rewritten + * when a base rotation moves the community to a new epoch (CORD-06). + * + * These lived only in Amethyst's `AccountConcordActions`, which meant a headless client (`amy`) + * could hold a rank it could never write under, and could not follow a Refounding at all. The + * logic is platform-agnostic — the only Android-shaped parts were the persistence and publish, + * which stay with the caller. Every function here decides *what* to adopt and returns it; the + * caller owns storing it and republishing the kind-13302 list. + * + * Everything fails closed: an undecryptable, mis-epoched or non-deriving delivery yields null, + * never a partially-adopted entry. + */ +object ConcordReceive { + /** + * The `control_root` a staff-making Grant delivered to the account behind [recipientSigner], + * or null when there is nothing to adopt (CORD-04 §3). + * + * Gated three ways, each of which fails closed: + * - only a Grant **our own fold honors** can deliver, so [authority] must already seat us as + * staff — a rogue cannot feed us a key by minting an edition nobody accepts; + * - the wrap must open under the granter↔member pairwise key, and name [entry]'s epoch, + * because compaction re-wraps a Grant head verbatim across Refoundings and a folded head + * can legitimately carry a wrap minted for a prior epoch; + * - the secret must derive to exactly the `control_pk` we already hold, or adopting it would + * split us off from the plane's readers. + * + * Returns null (not an error) when the entry already holds the secret, holds no `control_pk` + * to check against (a legacy pre-split community), or when we are not staff. + */ + suspend fun deliveredControlRoot( + entry: ConcordCommunityListEntry, + editions: List, + authority: AuthorityResolver, + recipientSigner: NostrSigner, + ): HexKey? { + val heldControlPk = entry.controlPk + if (entry.controlRoot != null || heldControlPk == null) return null + val me = recipientSigner.pubKey.lowercase() + if (!authority.isStaff(me)) return null + + val myGrantCoordinate = + ConcordKeyDerivation + .grantCoordinate(entry.id.hexToByteArray(), me.hexToByteArray()) + .toHexKey() + + return editions + .filter { it.entityKind == ControlEntityKind.GRANT && it.entityIdHex == myGrantCoordinate } + // Newest first: a re-issued Grant (a lost key, a head superseded before we fetched it) + // carries the fresher wrap. + .sortedByDescending { it.version } + .firstNotNullOfOrNull { edition -> + val wrap = ConcordJson.decodeOrNull(edition.content)?.controlWrap ?: return@firstNotNullOfOrNull null + val opened = ControlRootWrap.openOrNull(wrap, recipientSigner, edition.author) ?: return@firstNotNullOfOrNull null + if (opened.epoch != entry.rootEpoch) return@firstNotNullOfOrNull null + if (!ControlRootWrap.derivesTo(opened.controlRoot, entry.id.hexToByteArray(), entry.rootEpoch, heldControlPk)) return@firstNotNullOfOrNull null + opened.controlRoot.toHexKey() + } + } + + /** + * Whether [rotator] was allowed to launch the base rotation that [entry] is being moved by + * (CORD-06). `hasPermission`, never `effectivePermissions`: the latter ignores the banlist, so + * a banned BAN-holder could rotate the whole community out from under it. + */ + fun isAuthorizedRotator( + authority: AuthorityResolver, + rotator: HexKey, + ): Boolean = authority.isOwner(rotator) || authority.hasPermission(rotator, ConcordPermissions.BAN) + + /** + * The entry that results from adopting a base rotation to [newEpoch] — a pure rewrite, so the + * caller can diff, persist and publish it however its platform does. + * + * The epoch being left is banked in `heldRoots` **with the address it was folded at**, because + * a split epoch's Control address can never be re-derived, only remembered (CORD-02 §2) — that + * banked address is what keeps the anti-rollback floor rebuildable. A rotation that delivered + * no control material is a legacy pre-split one (CORD-06 §3): the new epoch folds at the legacy + * address, and the stale prior-epoch values must NOT be carried into it. `inviteRef` survives, + * or the *next* Refounding we are left out of becomes unrecoverable; `residue` survives, or we + * delete another client's unknown keys on every rekey. + */ + fun withAdoptedRoot( + entry: ConcordCommunityListEntry, + newRoot: ByteArray, + newEpoch: Long, + newControlPk: ByteArray? = null, + newControlRoot: ByteArray? = null, + ): ConcordCommunityListEntry = + ConcordCommunityListEntry( + id = entry.id, + owner = entry.owner, + ownerSalt = entry.ownerSalt, + root = newRoot.toHexKey(), + rootEpoch = newEpoch, + controlPk = newControlPk?.toHexKey(), + controlRoot = newControlRoot?.toHexKey(), + heldRoots = (entry.heldRoots + HeldRoot(entry.rootEpoch, entry.root, entry.controlPk, entry.controlRoot)).distinctBy { it.epoch }, + privateChannels = entry.privateChannels, + relays = entry.relays, + name = entry.name, + addedAt = entry.addedAt, + inviteRef = entry.inviteRef, + excludedAtEpoch = entry.excludedAtEpoch, + residue = entry.residue, + ) +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/cache/ICacheProvider.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/cache/ICacheProvider.kt index 6bdb38532e..7d9091967f 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/cache/ICacheProvider.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/cache/ICacheProvider.kt @@ -27,6 +27,7 @@ import com.vitorpamplona.amethyst.commons.model.User import com.vitorpamplona.quartz.nip01Core.core.Address import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.hints.HintIndexer /** * Cache provider interface for accessing cached Notes, Users, and Channels. @@ -41,6 +42,13 @@ import com.vitorpamplona.quartz.nip01Core.core.HexKey * - Platform-agnostic model layer */ interface ICacheProvider { + /** + * NIP-hints index (event/address/pubkey → relay) accumulated from consumed + * events. Used by the shared user/event finder assemblers to discover which + * relays are likely to hold a given user's metadata or a missing event. + */ + val relayHints: HintIndexer + /** * Gets a channel by Note reference. * Used for resolving relay hints for channel messages. @@ -134,5 +142,15 @@ interface ICacheProvider { */ fun getOrCreateUser(pubkey: HexKey): User? + /** + * Gets or creates a User by public key hex, swallowing any failure. + * Used by the event-finder relay-hint scan, which touches many potentially + * malformed pubkeys and must never throw mid-scan. + * + * @param key The user's public key in hex format + * @return The User (existing or newly created), or null on failure + */ + fun checkGetOrCreateUser(key: HexKey): User? = runCatching { getOrCreateUser(key) }.getOrNull() + fun justConsumeMyOwnEvent(event: Event): Boolean } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordCommunitySession.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordCommunitySession.kt index 7670ff69c6..58f6156b89 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordCommunitySession.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordCommunitySession.kt @@ -486,6 +486,9 @@ class ConcordCommunitySession( if (!ChannelChat.isTyping(rumor) || !ChannelChat.isBoundTo(rumor, channelIdHex, epoch)) return val who = rumor.pubKey.lowercase() if (who == myPubKey.lowercase()) return // never show my own typing back to me + // A banned member's messages are dropped everywhere, so their typing heartbeat must be too — + // otherwise they sit in the "… is typing" row forever in a channel they cannot be heard in. + if (_state.value?.authority?.isBanned(who) == true) return val now = TimeUtils.now() // Update the map and publish inside the lock so a concurrent heartbeat on another // channel can't publish an older snapshot last and drop this channel's typers. diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletArtifactPolicy.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletArtifactPolicy.kt new file mode 100644 index 0000000000..8cc23cdada --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletArtifactPolicy.kt @@ -0,0 +1,42 @@ +/* + * 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.napplet + +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip5aStaticWebsites.SiteAggregateHash +import com.vitorpamplona.quartz.nip5aStaticWebsites.tags.PathTag + +/** Pure NIP-5D artifact checks shared by every launch surface. */ +object NappletArtifactPolicy { + /** Returns the runtime-computed artifact identity, or null when the manifest must not execute. */ + fun verifiedAggregateHash( + paths: List, + declaredAggregateHash: HexKey?, + ): HexKey? { + val entry = paths.singleOrNull() ?: return null + if (entry.path != "/index.html" || !SHA256.matches(entry.hash)) return null + val computed = SiteAggregateHash.compute(paths) + if (declaredAggregateHash != null && !declaredAggregateHash.equals(computed, ignoreCase = true)) return null + return computed + } + + private val SHA256 = Regex("^[0-9a-fA-F]{64}$") +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletBroker.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletBroker.kt index 48800d865e..b7f9adf73d 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletBroker.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletBroker.kt @@ -33,6 +33,7 @@ import com.vitorpamplona.amethyst.commons.napplet.permissions.NappletPermissionL import com.vitorpamplona.amethyst.commons.napplet.permissions.PermissionDecision import com.vitorpamplona.amethyst.commons.napplet.protocol.NappletRequest import com.vitorpamplona.amethyst.commons.napplet.protocol.NappletResponse +import com.vitorpamplona.amethyst.commons.napplet.protocol.NappletStorageScope import com.vitorpamplona.amethyst.commons.napplet.protocol.toSignerOp import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner @@ -122,12 +123,6 @@ class NappletBroker( ): NappletResponse { val capability = request.capability - // shell.supports is capability negotiation: always answerable, no declaration/consent. - if (request is NappletRequest.ShellSupports) { - val cap = NappletCapability.fromNapDomain(request.domain) - return NappletResponse.Supported(cap != null && cap in declared) - } - if (capability !in declared) { return NappletResponse.Denied(capability, "This napplet did not declare the '${capability.name.lowercase()}' capability.") } @@ -149,7 +144,7 @@ class NappletBroker( // Keyboard/command action registration is a shell-mediated UI affordance, not key // access — declared is enough; it never prompts. request is NappletRequest.RegisterAction || request is NappletRequest.UnregisterAction -> true - // Cosmetic/negotiation capabilities (theme) never prompt. + // Cosmetic capabilities (theme) never prompt. !capability.requiresConsent -> true // A standing allow short-circuits, except for per-use capabilities (e.g. payments). ledger.decide(identity, capability) == PermissionDecision.ALLOW && !capability.requiresPerUseConsent -> true @@ -219,9 +214,6 @@ class NappletBroker( request: NappletRequest, ): NappletResponse = when (request) { - // Negotiation is resolved in handle(); execute() is never reached for it. - is NappletRequest.ShellSupports -> NappletResponse.Supported(true) - is NappletRequest.GetPublicKey -> NappletResponse.PublicKey(signer.pubKey) is NappletRequest.ThemeGet -> { @@ -267,24 +259,24 @@ class NappletBroker( is NappletRequest.StorageGet -> { val store = storage ?: return NappletResponse.Unsupported("storage.getItem") - NappletResponse.StorageValue(store.get(identity.coordinate, request.key)) + NappletResponse.StorageValue(store.get(storageCoordinate(identity, request.scope), request.key)) } is NappletRequest.StorageSet -> { val store = storage ?: return NappletResponse.Unsupported("storage.setItem") - store.set(identity.coordinate, request.key, request.value) + store.set(storageCoordinate(identity, request.scope), request.key, request.value) NappletResponse.Done } is NappletRequest.StorageRemove -> { val store = storage ?: return NappletResponse.Unsupported("storage.removeItem") - store.remove(identity.coordinate, request.key) + store.remove(storageCoordinate(identity, request.scope), request.key) NappletResponse.Done } is NappletRequest.StorageKeys -> { val store = storage ?: return NappletResponse.Unsupported("storage.keys") - NappletResponse.Strings(store.keys(identity.coordinate)) + NappletResponse.Strings(store.keys(storageCoordinate(identity, request.scope))) } is NappletRequest.NotifyCreate -> { @@ -316,8 +308,38 @@ class NappletBroker( is NappletRequest.ResourceBytes -> { val gateway = resource ?: return NappletResponse.Unsupported("resource.bytes") - val fetched = gateway.fetch(request.url, identity.coordinate) ?: return NappletResponse.Failed("Could not fetch the resource.") - NappletResponse.Bytes(fetched.bytes, fetched.contentType) + when (val fetched = gateway.fetch(request.url, identity.coordinate)) { + is NappletResourceResult.Success -> NappletResponse.Bytes(fetched.resource.bytes, fetched.resource.contentType) + is NappletResourceResult.Failure -> NappletResponse.ResourceFailure(fetched.error, fetched.message) + } + } + + is NappletRequest.ResourceInfo -> { + resource ?: return NappletResponse.Unsupported("resource.info") + NappletResponse.ResourceInfo( + schemes = listOf("data", "https", "blossom", "nostr"), + maxBytes = RESOURCE_MAX_BYTES, + maxUrls = RESOURCE_MAX_URLS, + ) + } + + is NappletRequest.ResourceBytesMany -> { + val gateway = resource ?: return NappletResponse.Unsupported("resource.bytesMany") + if (request.urls.isEmpty()) return NappletResponse.ResourceFailure("invalid-request", "Resource URL list is empty.") + if (request.urls.size > RESOURCE_MAX_URLS) return NappletResponse.ResourceFailure("too-large", "Resource URL limit exceeded.") + NappletResponse.ResourceItems( + request.urls.map { url -> + when (val fetched = gateway.fetch(url, identity.coordinate)) { + is NappletResourceResult.Success -> + NappletResponse.ResourceItem( + url = url, + resource = NappletResponse.Bytes(fetched.resource.bytes, fetched.resource.contentType), + ) + is NappletResourceResult.Failure -> + NappletResponse.ResourceItem(url = url, error = fetched.error, message = fetched.message) + } + }, + ) } is NappletRequest.UploadBlob -> { @@ -353,6 +375,15 @@ class NappletBroker( tags + arrayOf(arrayOf("p", recipient)) } + private fun storageCoordinate( + identity: NappletIdentity, + scope: NappletStorageScope, + ): String = + when (scope) { + NappletStorageScope.SHARED -> identity.storageCoordinate + NappletStorageScope.INSTANCE -> identity.instanceStorageCoordinate + } + /** * Shows the first-connect "Connect to Nostr" dialog if no signer policy exists yet. * On success, stores the chosen policy and bulk-grants all declared non-payment capabilities. @@ -505,16 +536,6 @@ class NappletBroker( } } - /** - * True when [capability] carries a standing denial for [identity]. Push-subscription edge ops - * (identity.watch and friends) short-circuit before [handle], so they have to apply the same - * "a standing denial always wins" rule themselves rather than trusting the declaration alone. - */ - suspend fun isDenied( - identity: NappletIdentity, - capability: NappletCapability, - ): Boolean = ledger.decide(identity, capability) == PermissionDecision.DENY - companion object { /** * How long (ms) a Cancel on the first-connect dialog suppresses re-prompting for the same app. @@ -522,5 +543,7 @@ class NappletBroker( * the dialog per request; short enough that a deliberate user retry seconds later prompts again. */ private const val CANCEL_REPROMPT_COOLDOWN_MS = 3_000L + private const val RESOURCE_MAX_BYTES = 10L * 1024L * 1024L + private const val RESOURCE_MAX_URLS = 16 } } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletBrokerCollaborators.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletBrokerCollaborators.kt index e229884068..27b84f70de 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletBrokerCollaborators.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletBrokerCollaborators.kt @@ -156,6 +156,17 @@ class NappletResource( val contentType: String, ) +sealed interface NappletResourceResult { + data class Success( + val resource: NappletResource, + ) : NappletResourceResult + + data class Failure( + val error: String, + val message: String? = null, + ) : NappletResourceResult +} + /** * Bridges the broker to sandboxed resource fetching for [NappletCapability.RESOURCE] * (`resource.bytes`). The host fetches https/blossom/nostr/data URLs on the applet's behalf — @@ -164,13 +175,14 @@ class NappletResource( * * [coordinate] is the calling applet's identity coordinate (`author:identifier`), so the host can * route the fetch the same way the applet's own page loads — through Tor or the open web — per that - * applet's/site's network mode. + * applet's/site's network mode. Failures carry the stable NAP-RESOURCE error code rather than + * collapsing policy rejections and network failures into one nullable result. */ fun interface NappletResourceGateway { suspend fun fetch( url: String, coordinate: String, - ): NappletResource? + ): NappletResourceResult } /** A completed upload: where the blob lives plus NIP-94-ish metadata. */ diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletCapability.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletCapability.kt index a781dc7bc1..33ee94cf7c 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletCapability.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletCapability.kt @@ -25,14 +25,11 @@ package com.vitorpamplona.amethyst.commons.napplet * (`napplet/naps`, `@napplet/web`). A napplet declares the domains it needs via `requires` tags; * [fromNapDomain] maps each bare domain string to the capability the broker enforces. * - * The mapping is **default-deny**: an unrecognized NAP domain maps to `null` and the shell must - * surface it as unknown rather than silently granting it. Domains we don't yet broker - * (`intent`, `media`, `config`, `outbox`, `ifc`, `cvm`) therefore resolve to `null` for now. + * The mapping is **default-deny**: an unrecognized or only partially implemented NAP domain maps + * to `null`. Keeping a broker implementation below does not advertise conformance; only domains + * whose current NAP contract is implemented are injected into `window.napplet`. */ enum class NappletCapability { - /** `shell` — capability negotiation (`shell.supports`). Always available; needs no consent. */ - SHELL, - /** `identity` — read-only identity queries (`getPublicKey`, `onChanged`). */ IDENTITY, @@ -70,13 +67,13 @@ enum class NappletCapability { ; /** - * Whether using this capability requires user consent. Negotiation ([SHELL]) and the cosmetic, - * read-only theme read ([THEME]) never prompt; everything else does (subject to the broker's + * Whether using this capability requires user consent. The cosmetic, read-only theme read + * ([THEME]) never prompts; everything else does (subject to the broker's * signer-self-gating and standing-grant rules). [INC] is authorized at the router edge on its * declaration alone, so it never reaches the consent path regardless of this flag. */ val requiresConsent: Boolean - get() = this != SHELL && this != THEME + get() = this != THEME /** * Whether the user must confirm **every single use** — no standing auto-approval. True for @@ -96,25 +93,22 @@ enum class NappletCapability { companion object { /** - * Maps a bare NAP domain to the capability the broker enforces, case-insensitively. - * Returns `null` for any domain the shell does not recognize — callers MUST treat that as - * "unknown, do not grant". + * Maps a bare, currently supported NAP domain to the capability the broker enforces. + * Returns `null` for unknown and partial/legacy domains — callers MUST treat that as + * "unavailable, do not inject or grant". NIP-5D domain names are exact lowercase strings. */ fun fromNapDomain(domain: String): NappletCapability? = - when (domain.trim().lowercase()) { - "shell" -> SHELL + when (domain) { "identity" -> IDENTITY - "keys" -> KEYS - "relay", "relays" -> RELAY + "relay" -> RELAY "storage" -> STORAGE - "value" -> VALUE "resource" -> RESOURCE - "upload" -> UPLOAD "theme" -> THEME - "notify" -> NOTIFY - "inc" -> INC else -> null } + + /** Exact NIP-5D domain names Amethyst currently exposes through its injection prelude. */ + val supportedNapDomains: Set = setOf("identity", "relay", "storage", "resource", "theme") } } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletIdentity.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletIdentity.kt index a543bf505f..9d2a759ab6 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletIdentity.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletIdentity.kt @@ -41,7 +41,15 @@ data class NappletIdentity( val authorPubKey: HexKey, val identifier: String, val aggregateHash: HexKey? = null, + /** Opaque host-assigned lifetime id used only for NAP-STORAGE's instance scope. */ + val instanceId: String? = null, ) { /** The ledger key: coordinate only, never the [aggregateHash], so grants survive updates. */ val coordinate: String = "$authorPubKey:$identifier" + + /** NAP-STORAGE shared namespace: exact publisher + dTag + verified artifact identity. */ + val storageCoordinate: String = "$coordinate:${aggregateHash.orEmpty()}" + + /** NAP-STORAGE instance namespace, stable for this host launch and isolated from sibling launches. */ + val instanceStorageCoordinate: String = "$storageCoordinate:instance:${instanceId.orEmpty()}" } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletWebContract.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletWebContract.kt index 74f960c331..43d780edf3 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletWebContract.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletWebContract.kt @@ -41,33 +41,19 @@ object NappletWebContract { const val SHELL_URL = "$ORIGIN/__shell__" /** - * The applet runs on its **own per-applet origin** — a unique subdomain of [HOST] — served at the - * origin root, NOT on the shell [ORIGIN]. Two reasons, both load-bearing: - * - * 1. **A real (non-opaque) origin is what gives the applet working, persistent storage.** An - * `allow-scripts`-only opaque-origin iframe has no `localStorage`/`IndexedDB`/service worker - * (reads throw `SecurityError`), which crash-loops essentially every SPA. A real origin with - * `allow-same-origin` has them, scoped and isolated per applet (subdomains don't share - * storage), so applets can't read each other's data. - * 2. **Keeping it on a DISTINCT origin from the shell is what preserves the trust boundary.** The - * native bridge is origin-restricted to the shell [ORIGIN]; the applet, being cross-origin, - * still can't reach it (nor read the shell DOM) — it talks only via `postMessage`, which the - * shell relays. `allow-same-origin` is therefore safe here precisely because the applet is - * same-origin only with *itself*, never with the shell. - * - * The applet is served at its origin root because SPA bundlers (Vite, CRA, webpack, nsyte, …) emit - * **absolute** asset URLs (`/assets/app.js`, `/fonts/x.woff2`) that resolve against the origin root. - * - * [appId] must be a stable, unique, DNS-label-safe token per applet (the host derives it from the - * applet's author + identifier), so the same applet keeps its storage across launches. + * Per-site origin retained for Amethyst's NIP-5A WEBSITE profile. NIP-5D napplets never navigate + * here: their verified, self-contained `/index.html` is assigned through `srcdoc` and therefore + * executes with an opaque origin in an `allow-scripts`-only sandbox. */ fun appOrigin(appId: String): String = "https://$appId.$HOST" /** True for the shell host and any per-applet subdomain — i.e. everything we serve internally. */ fun isInternalHost(host: String?): Boolean = host == HOST || (host != null && host.endsWith(".$HOST")) - /** Placeholder in [SHELL_HTML_PATH] the host replaces with the per-applet [appOrigin] before serving. */ + /** Placeholders in [SHELL_HTML_PATH] replaced by the host before serving the trusted shell. */ const val APP_ORIGIN_PLACEHOLDER = "__APP_ORIGIN__" + const val APP_SANDBOX_PLACEHOLDER = "__APP_SANDBOX__" + const val APP_BOOTSTRAP_PLACEHOLDER = "__APP_BOOTSTRAP__" /** Name of the origin-restricted native bridge the shell (and only the shell) can reach. */ const val BRIDGE_NAME = "__nappletBridge" @@ -76,24 +62,72 @@ object NappletWebContract { * CSP for the shell document: it may inline its own bridge script/style and frame **only this * applet's** origin, but has no network and cannot navigate or submit anywhere. */ - fun shellCsp(appOrigin: String): String = + fun shellCsp(frameSource: String): String = "default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; " + - "frame-src $appOrigin; base-uri 'none'; form-action 'none'" + "frame-src $frameSource; base-uri 'none'; form-action 'none'; frame-ancestors 'none'" /** - * CSP for the applet document. The applet has a real origin now, so `'self'` resolves to its own - * per-applet origin and the shell origin is deliberately NOT granted. The key lever is - * `connect-src 'none'`: the applet gets **no** direct network — every fetch goes through the - * brokered, consent-gated `resource.bytes`. + * Conservative NIP-5D CSP injected as the first element of the verified napplet's `head` before + * the runtime prelude. A `srcdoc` napplet has an opaque origin, so self-hosted subresources are + * intentionally unavailable; a conforming napplet is one self-contained `/index.html`. */ const val APP_CSP: String = - "default-src 'self'; " + - "script-src 'self' 'unsafe-inline'; " + - "style-src 'self' 'unsafe-inline'; " + - "img-src 'self' data: blob:; " + - "font-src 'self' data:; " + - "media-src 'self' blob: data:; " + - "connect-src 'none'; frame-src 'none'; object-src 'none'; base-uri 'self'; form-action 'none'" + "default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; " + + "img-src data: blob:; font-src data:; connect-src 'none'; worker-src 'none'; " + + "child-src 'none'; frame-src 'none'; media-src 'none'; object-src 'none'; " + + "manifest-src 'none'; base-uri 'none'; form-action 'none'" + + /** + * Injects host-owned policy and the runtime prelude before any authored element in `head`. + * [locked] is the NIP-5D posture; WEBSITE callers deliberately retain Amethyst's NIP-07 and + * normal-origin behavior. Only syntactically valid, explicitly authorized NAP domains are + * projected onto `window.napplet` by the trusted [shimJs]. + */ + fun injectPrelude( + html: ByteArray, + shimJs: String, + declaredDomains: List, + locked: Boolean, + injectNip07: Boolean = false, + imeProxy: Boolean = false, + ): ByteArray { + val text = html.decodeToString() + val policy = + if (locked) { + "" + } else { + "" + } + val style = "" + val flags = + "" + val safeDomains = + declaredDomains + .filter { it.matches(NAP_DOMAIN) && it in NappletCapability.supportedNapDomains } + .distinct() + val domainsJson = safeDomains.joinToString(prefix = "[", postfix = "]") { "\"$it\"" } + val prelude = "$policy$style$flags" + val headIdx = text.indexOf("= 0 -> { + val close = text.indexOf('>', headIdx) + if (close >= 0) text.substring(0, close + 1) + prelude + text.substring(close + 1) else prelude + text + } + else -> { + val htmlIdx = text.indexOf("= 0) text.indexOf('>', htmlIdx) else -1 + if (htmlClose >= 0) { + text.substring(0, htmlClose + 1) + "$prelude" + text.substring(htmlClose + 1) + } else { + "$prelude$text" + } + } + } + return injected.encodeToByteArray() + } const val SHELL_HTML_PATH = "files/napplet/shell.html" const val SHIM_JS_PATH = "files/napplet/shim.js" @@ -114,4 +148,6 @@ object NappletWebContract { /** The `window.napplet` client shim a host injects into the applet document. */ @OptIn(ExperimentalResourceApi::class) suspend fun shimJs(): ByteArray = Res.readBytes(SHIM_JS_PATH) + + private val NAP_DOMAIN = Regex("^[a-z][a-z0-9-]*$") } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/protocol/NappletRequest.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/protocol/NappletRequest.kt index d85925650f..25a58bca43 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/protocol/NappletRequest.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/protocol/NappletRequest.kt @@ -24,6 +24,11 @@ import com.vitorpamplona.amethyst.commons.napplet.NappletCapability import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +enum class NappletStorageScope { + SHARED, + INSTANCE, +} + /** * A capability request from a napplet, after it has crossed the postMessage + IPC edge * and been deserialized into a typed object. Each variant declares the [capability] the @@ -68,14 +73,6 @@ sealed interface NappletRequest { override val capability get() = NappletCapability.THEME } - /** `shell.supports(domain, protocol?)` — capability negotiation; always answerable, no consent. */ - data class ShellSupports( - val domain: String, - val protocol: String? = null, - ) : NappletRequest { - override val capability get() = NappletCapability.SHELL - } - /** * Publish an event built from an **unsigned template**. The napplet supplies only `kind`, * `tags`, and `content`; the shell sets `pubkey` from the real signer, stamps `created_at`, @@ -198,6 +195,7 @@ sealed interface NappletRequest { /** Read a value from this napplet's sandboxed key-value store (`storage.getItem`). */ data class StorageGet( val key: String, + val scope: NappletStorageScope = NappletStorageScope.SHARED, ) : NappletRequest { override val capability get() = NappletCapability.STORAGE } @@ -206,6 +204,7 @@ sealed interface NappletRequest { data class StorageSet( val key: String, val value: String, + val scope: NappletStorageScope = NappletStorageScope.SHARED, ) : NappletRequest { override val capability get() = NappletCapability.STORAGE } @@ -213,12 +212,15 @@ sealed interface NappletRequest { /** Remove a value from this napplet's sandboxed key-value store (`storage.removeItem`). */ data class StorageRemove( val key: String, + val scope: NappletStorageScope = NappletStorageScope.SHARED, ) : NappletRequest { override val capability get() = NappletCapability.STORAGE } /** List the keys this napplet has stored (`storage.keys`). */ - data object StorageKeys : NappletRequest { + data class StorageKeys( + val scope: NappletStorageScope = NappletStorageScope.SHARED, + ) : NappletRequest { override val capability get() = NappletCapability.STORAGE } @@ -282,6 +284,18 @@ sealed interface NappletRequest { override val capability get() = NappletCapability.RESOURCE } + /** Describe the bounded schemes and limits of this shell's existing resource broker. */ + data object ResourceInfo : NappletRequest { + override val capability get() = NappletCapability.RESOURCE + } + + /** Fetch several resources in input order, returning a per-URL success/error record. */ + data class ResourceBytesMany( + val urls: List, + ) : NappletRequest { + override val capability get() = NappletCapability.RESOURCE + } + /** Upload a blob to the user's Blossom server (`upload.upload`). */ data class UploadBlob( val bytes: ByteArray, diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/protocol/NappletResponse.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/protocol/NappletResponse.kt index 63ad63afff..07f0394b0c 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/protocol/NappletResponse.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/protocol/NappletResponse.kt @@ -51,11 +51,6 @@ sealed interface NappletResponse { val events: List, ) : NappletResponse - /** Result of `shell.supports(domain)`. */ - data class Supported( - val supported: Boolean, - ) : NappletResponse - /** Result of `keys.registerAction`: the shell-assigned [actionId] and the [binding] it honored (e.g. `"Ctrl+S"`). */ data class ActionRegistered( val actionId: String, @@ -97,6 +92,28 @@ sealed interface NappletResponse { override fun hashCode(): Int = 31 * contentType.hashCode() + bytes.contentHashCode() } + data class ResourceInfo( + val schemes: List, + val maxBytes: Long, + val maxUrls: Int, + ) : NappletResponse + + data class ResourceItem( + val url: String, + val resource: Bytes? = null, + val error: String? = null, + val message: String? = null, + ) + + data class ResourceItems( + val items: List, + ) : NappletResponse + + data class ResourceFailure( + val error: String, + val message: String? = null, + ) : NappletResponse + /** Result of an `upload.upload`; [url] is where the blob can be fetched, plus NIP-94-ish metadata. */ data class Uploaded( val url: String, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/SingleSubNoEoseCacheEoseManager.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/eoseManagers/SingleSubNoEoseCacheEoseManager.kt similarity index 85% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/SingleSubNoEoseCacheEoseManager.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/eoseManagers/SingleSubNoEoseCacheEoseManager.kt index a80357e283..622d9f8fbb 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/SingleSubNoEoseCacheEoseManager.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/eoseManagers/SingleSubNoEoseCacheEoseManager.kt @@ -18,11 +18,9 @@ * 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.eoseManagers +package com.vitorpamplona.amethyst.commons.relayClient.eoseManagers -import com.vitorpamplona.amethyst.commons.relayClient.eoseManagers.BaseEoseManager import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.attributedTo -import com.vitorpamplona.amethyst.service.relayClient.AccountScopedQuery import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter @@ -85,9 +83,10 @@ abstract class SingleSubNoEoseCacheEoseManager( /** * The account behind [key], when the key is account-scoped. Null for keys about other users. * - * Keyed on [AccountScopedQuery] rather than a concrete query-state type: the home feed uses - * HomeQueryState, notifications use AccountQueryState, and checking one concrete class filed the - * other under "not attributed" despite both being built from a single account's data. + * Account-agnostic in commons: front ends that want single-account attribution override this + * (see the amethyst `AccountScopedSingleSubNoEoseCacheEoseManager`, which reads + * `(key as? AccountScopedQuery)?.account?.userProfile()?.pubkeyHex`). The default returns null, + * so pooled / cross-account subscriptions are filed as "not attributed". */ - private fun accountPubKeyOf(key: Any?): String? = (key as? AccountScopedQuery)?.account?.userProfile()?.pubkeyHex + open fun accountPubKeyOf(key: Any?): String? = null } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/EventFinderFilterAssembler.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/event/EventFinderFilterAssembler.kt similarity index 70% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/EventFinderFilterAssembler.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/event/EventFinderFilterAssembler.kt index f1f320ae50..a5e1ed022c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/EventFinderFilterAssembler.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/event/EventFinderFilterAssembler.kt @@ -18,36 +18,35 @@ * 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.reqCommand.event +package com.vitorpamplona.amethyst.commons.relayClient.event import androidx.compose.runtime.Stable +import com.vitorpamplona.amethyst.commons.model.Note +import com.vitorpamplona.amethyst.commons.model.cache.ICacheProvider import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager -import com.vitorpamplona.amethyst.model.Account -import com.vitorpamplona.amethyst.model.LocalCache -import com.vitorpamplona.amethyst.model.Note -import com.vitorpamplona.amethyst.service.relayClient.AccountScopedQuery -import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.loaders.AddressableAuthorRelayLoaderSubAssembler -import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.loaders.NoteEventLoaderSubAssembler -import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.watchers.EventWatcherSubAssembler -import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.UserFinderFilterAssembler +import com.vitorpamplona.amethyst.commons.relayClient.event.loaders.AddressableAuthorRelayLoaderSubAssembler +import com.vitorpamplona.amethyst.commons.relayClient.event.loaders.NoteEventLoaderSubAssembler +import com.vitorpamplona.amethyst.commons.relayClient.event.watchers.EventWatcherSubAssembler +import com.vitorpamplona.amethyst.commons.relayClient.user.UserFinderAccount +import com.vitorpamplona.amethyst.commons.relayClient.user.UserFinderFilterAssembler import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient // This allows multiple screen to be listening to tags, even the same tag @Stable class EventFinderQueryState( val note: Note, - override val account: Account, -) : AccountScopedQuery + val account: UserFinderAccount, +) @Stable class EventFinderFilterAssembler( client: INostrClient, - cache: LocalCache, + cache: ICacheProvider, userFinder: UserFinderFilterAssembler, ) : ComposeSubscriptionManager() { val group = listOf( - NoteEventLoaderSubAssembler(client, ::allKeys), + NoteEventLoaderSubAssembler(client, cache, ::allKeys), EventWatcherSubAssembler(client, ::allKeys), AddressableAuthorRelayLoaderSubAssembler(cache, ::allKeys, userFinder), ) diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/event/EventFinderFilterAssemblerSubscription.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/event/EventFinderFilterAssemblerSubscription.kt new file mode 100644 index 0000000000..bf9f637ec1 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/event/EventFinderFilterAssemblerSubscription.kt @@ -0,0 +1,84 @@ +/* + * 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.relayClient.event + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.runtime.staticCompositionLocalOf +import com.vitorpamplona.amethyst.commons.model.Note +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.LifecycleAwareKeyDataSourceSubscription +import com.vitorpamplona.amethyst.commons.relayClient.user.LocalUserFinderAccount +import com.vitorpamplona.amethyst.commons.relayClient.user.UserFinderAccount + +/** + * The shared per-note event data source (reactions / zaps / reposts / replies / + * OTS / references) for the current front end. Provided once near the composition + * root (Android via AppModules, Desktop via its subscriptions coordinator). + * Reading it without a provider is a programming error — the per-note observers + * must never be reachable from a composition that has no relay client (e.g. the + * Android `:napplet` sandbox process). + * + * The *account* half is reused from the user-finder: [LocalUserFinderAccount] + * already carries the narrow relay-hint seam the event loaders need. + */ +val LocalEventFinder = + staticCompositionLocalOf { + error("LocalEventFinder not provided") + } + +/** + * Subscribes to relay updates for [note]'s interactions (reactions, zaps, + * reposts, replies, …) for as long as this composable is in composition, + * coalesced with every other on-screen note into batched REQs by [dataSource]. + * + * Like the user-finder, because a `LazyColumn` composes only the visible window + * (+ a small prefetch buffer) this means "load interactions only for notes + * currently on screen" — [LifecycleAwareKeyDataSourceSubscription] unsubscribes + * ~30s after the row leaves composition or the app is backgrounded. + */ +@Composable +fun EventFinderFilterAssemblerSubscription( + note: Note, + account: UserFinderAccount, + dataSource: EventFinderFilterAssembler, +) { + // Different screens get their own query-state instance even when tracking + // the same note; the assembler dedups to one REQ per note. + val state = + remember(note, account) { + EventFinderQueryState(note, account) + } + + LifecycleAwareKeyDataSourceSubscription(state, dataSource) +} + +/** + * Convenience overload that reads the front end's [LocalEventFinder] and + * [LocalUserFinderAccount] from the composition. + */ +@Composable +fun EventFinderFilterAssemblerSubscription(note: Note) { + EventFinderFilterAssemblerSubscription( + note = note, + account = LocalUserFinderAccount.current, + dataSource = LocalEventFinder.current, + ) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/loaders/AddressableAuthorRelayLoaderSubAssembler.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/event/loaders/AddressableAuthorRelayLoaderSubAssembler.kt similarity index 84% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/loaders/AddressableAuthorRelayLoaderSubAssembler.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/event/loaders/AddressableAuthorRelayLoaderSubAssembler.kt index 9dba6ed2bd..46dbae7ac7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/loaders/AddressableAuthorRelayLoaderSubAssembler.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/event/loaders/AddressableAuthorRelayLoaderSubAssembler.kt @@ -18,15 +18,17 @@ * 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.reqCommand.event.loaders +package com.vitorpamplona.amethyst.commons.relayClient.event.loaders +import com.vitorpamplona.amethyst.commons.model.AddressableNote +import com.vitorpamplona.amethyst.commons.model.cache.ICacheProvider import com.vitorpamplona.amethyst.commons.relayClient.eoseManagers.IEoseManager +import com.vitorpamplona.amethyst.commons.relayClient.event.EventFinderQueryState +import com.vitorpamplona.amethyst.commons.relayClient.user.UserFinderFilterAssembler +import com.vitorpamplona.amethyst.commons.relayClient.user.UserFinderQueryState import com.vitorpamplona.amethyst.commons.service.BundledUpdate -import com.vitorpamplona.amethyst.model.AddressableNote -import com.vitorpamplona.amethyst.model.LocalCache -import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.EventFinderQueryState -import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.UserFinderFilterAssembler -import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.UserFinderQueryState +import com.vitorpamplona.amethyst.commons.util.KmpLock +import com.vitorpamplona.amethyst.commons.util.withLock import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.IO @@ -41,13 +43,14 @@ import kotlinx.coroutines.IO * relay list arrives, [EventFinderFilterAssembler] is invalidated and can query the correct relay. */ class AddressableAuthorRelayLoaderSubAssembler( - val cache: LocalCache, + val cache: ICacheProvider, val allKeys: () -> Set, val userFinder: UserFinderFilterAssembler, ) : IEoseManager { // Private monitor: @Synchronized locks on `this`, which leaves the instance's monitor - // reachable to anything holding a reference to this assembler. - private val lock = Any() + // reachable to anything holding a reference to this assembler. KmpLock (not `synchronized`) + // because this file lives in commonMain and must compile for the iOS targets too. + private val lock = KmpLock() // Only ever touched while holding [lock]. See commit() and destroy(). private var activeSubscriptions: Set = emptySet() @@ -69,7 +72,7 @@ class AddressableAuthorRelayLoaderSubAssembler( val note = key.note if (note is AddressableNote && note.event == null) { val author = cache.getOrCreateUser(note.address.pubKeyHex) - if (author.authorRelayList() == null) { + if (author != null && author.authorRelayList() == null) { needed.add(UserFinderQueryState(author, key.account)) } } @@ -92,7 +95,7 @@ class AddressableAuthorRelayLoaderSubAssembler( * never call back into this class. Revisit if that changes. */ private fun commit(needed: Set) { - synchronized(lock) { + lock.withLock { if (destroyed) return userFinder.subscribe((needed - activeSubscriptions).toList()) @@ -103,7 +106,7 @@ class AddressableAuthorRelayLoaderSubAssembler( } override fun destroy() { - synchronized(lock) { + lock.withLock { destroyed = true bundler.cancel() userFinder.unsubscribe(activeSubscriptions.toList()) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/loaders/FilterMissingAddressables.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/event/loaders/FilterMissingAddressables.kt similarity index 79% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/loaders/FilterMissingAddressables.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/event/loaders/FilterMissingAddressables.kt index 41eac22796..f52e1a3242 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/loaders/FilterMissingAddressables.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/event/loaders/FilterMissingAddressables.kt @@ -18,33 +18,36 @@ * 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.reqCommand.event.loaders +package com.vitorpamplona.amethyst.commons.relayClient.event.loaders +import com.vitorpamplona.amethyst.commons.model.AddressableNote +import com.vitorpamplona.amethyst.commons.model.cache.ICacheProvider +import com.vitorpamplona.amethyst.commons.relayClient.event.EventFinderQueryState import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose -import com.vitorpamplona.amethyst.model.AddressableNote -import com.vitorpamplona.amethyst.model.LocalCache -import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.EventFinderQueryState import com.vitorpamplona.quartz.nip01Core.core.Address import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.utils.mapOfSet -fun potentialRelaysToFindAddress(note: AddressableNote): Set { +fun potentialRelaysToFindAddress( + cache: ICacheProvider, + note: AddressableNote, +): Set { val set = mutableSetOf() - LocalCache.getOrCreateUser(note.address.pubKeyHex).outboxRelays()?.let { + cache.getOrCreateUser(note.address.pubKeyHex)?.outboxRelays()?.let { set.addAll(it) } - set.addAll(LocalCache.relayHints.hintsForAddress(note.idHex)) + set.addAll(cache.relayHints.hintsForAddress(note.idHex)) - LocalCache.getAnyChannel(note)?.relays()?.let { set.addAll(it) } + cache.getAnyChannel(note)?.relays()?.let { set.addAll(it) } note.replyTo?.forEach { parentNote -> set.addAll(parentNote.relays) - LocalCache.getAnyChannel(parentNote)?.relays()?.let { set.addAll(it) } + cache.getAnyChannel(parentNote)?.relays()?.let { set.addAll(it) } parentNote.author?.inboxRelays()?.let { set.addAll(it) } } @@ -52,7 +55,7 @@ fun potentialRelaysToFindAddress(note: AddressableNote): Set note.replies.forEach { childNote -> set.addAll(childNote.relays) - LocalCache.getAnyChannel(childNote)?.relays()?.let { set.addAll(it) } + cache.getAnyChannel(childNote)?.relays()?.let { set.addAll(it) } childNote.author?.outboxRelays()?.let { set.addAll(it) } } @@ -72,13 +75,16 @@ fun potentialRelaysToFindAddress(note: AddressableNote): Set return set } -fun filterMissingAddressables(keys: List): List { +fun filterMissingAddressables( + cache: ICacheProvider, + keys: List, +): List { val addressesPerRelay = mapOfSet { keys.forEach { key -> - val default = key.account.followPlusAllMineWithSearch.flow.value + val default = key.account.followPlusAllMineWithSearchRelays() if (key.note is AddressableNote && key.note.event == null) { - potentialRelaysToFindAddress(key.note).ifEmpty { default }.forEach { relayUrl -> + potentialRelaysToFindAddress(cache, key.note).ifEmpty { default }.forEach { relayUrl -> add(relayUrl, key.note.address) } } @@ -86,7 +92,7 @@ fun filterMissingAddressables(keys: List): List if (note is AddressableNote && note.event == null) { - potentialRelaysToFindAddress(note).ifEmpty { default }.forEach { relayUrl -> + potentialRelaysToFindAddress(cache, note).ifEmpty { default }.forEach { relayUrl -> add(relayUrl, note.address) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/loaders/FilterMissingEvents.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/event/loaders/FilterMissingEvents.kt similarity index 79% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/loaders/FilterMissingEvents.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/event/loaders/FilterMissingEvents.kt index f9f545fc00..6675cf7538 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/loaders/FilterMissingEvents.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/event/loaders/FilterMissingEvents.kt @@ -18,33 +18,36 @@ * 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.reqCommand.event.loaders +package com.vitorpamplona.amethyst.commons.relayClient.event.loaders +import com.vitorpamplona.amethyst.commons.model.AddressableNote import com.vitorpamplona.amethyst.commons.model.Channel +import com.vitorpamplona.amethyst.commons.model.Note +import com.vitorpamplona.amethyst.commons.model.cache.ICacheProvider +import com.vitorpamplona.amethyst.commons.relayClient.event.EventFinderQueryState import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose -import com.vitorpamplona.amethyst.model.AddressableNote -import com.vitorpamplona.amethyst.model.LocalCache -import com.vitorpamplona.amethyst.model.Note -import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.EventFinderQueryState import com.vitorpamplona.quartz.nip01Core.hints.PubKeyHintProvider import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.utils.mapOfSet -fun potentialRelaysToFindEvent(note: Note): Set { +fun potentialRelaysToFindEvent( + cache: ICacheProvider, + note: Note, +): Set { val set = mutableSetOf() - set.addAll(LocalCache.relayHints.hintsForEvent(note.idHex)) + set.addAll(cache.relayHints.hintsForEvent(note.idHex)) note.author?.outboxRelays()?.let { set.addAll(it) } - LocalCache.getAnyChannel(note)?.relays()?.let { set.addAll(it) } + cache.getAnyChannel(note)?.relays()?.let { set.addAll(it) } note.replyTo?.forEach { parentNote -> set.addAll(parentNote.relays) - LocalCache.getAnyChannel(parentNote)?.relays()?.let { set.addAll(it) } + cache.getAnyChannel(parentNote)?.relays()?.let { set.addAll(it) } parentNote.author?.inboxRelays()?.let { set.addAll(it) } } @@ -52,7 +55,7 @@ fun potentialRelaysToFindEvent(note: Note): Set { note.replies.forEach { childNote -> set.addAll(childNote.relays) - LocalCache.getAnyChannel(childNote)?.relays()?.let { set.addAll(it) } + cache.getAnyChannel(childNote)?.relays()?.let { set.addAll(it) } childNote.author?.outboxRelays()?.let { set.addAll(it) } } @@ -81,7 +84,7 @@ fun potentialRelaysToFindEvent(note: Note): Set { val noteEvent = parent.event if (noteEvent is PubKeyHintProvider) { noteEvent.linkedPubKeys().forEach { potentialAuthor -> - LocalCache.checkGetOrCreateUser(potentialAuthor)?.let { potentialAuthor -> + cache.checkGetOrCreateUser(potentialAuthor)?.let { potentialAuthor -> potentialAuthor.outboxRelays()?.let { set.addAll(it) } potentialAuthor.inboxRelays()?.let { set.addAll(it) } } @@ -98,18 +101,21 @@ fun potentialRelaysToFindEvent(note: Note): Set { return set } -fun filterMissingEvents(keys: List): List { +fun filterMissingEvents( + cache: ICacheProvider, + keys: List, +): List { val eventsPerRelay = mapOfSet { keys.forEach { key -> - val default = key.account.followPlusAllMineWithSearch.flow.value + val default = key.account.followPlusAllMineWithSearchRelays() if (key.note !is AddressableNote && key.note.event == null) { - potentialRelaysToFindEvent(key.note).ifEmpty { default }.forEach { relayUrl -> + potentialRelaysToFindEvent(cache, key.note).ifEmpty { default }.forEach { relayUrl -> add(relayUrl, key.note.idHex) } - key.account.searchRelayList.flow.value.forEach { relayUrl -> + key.account.searchOnlyRelays().forEach { relayUrl -> add(relayUrl, key.note.idHex) } } @@ -117,7 +123,7 @@ fun filterMissingEvents(keys: List): List if (note !is AddressableNote && note.event == null) { - potentialRelaysToFindEvent(note).ifEmpty { default }.forEach { relayUrl -> + potentialRelaysToFindEvent(cache, note).ifEmpty { default }.forEach { relayUrl -> add(relayUrl, note.idHex) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/loaders/NoteEventLoaderSubAssembler.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/event/loaders/NoteEventLoaderSubAssembler.kt similarity index 67% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/loaders/NoteEventLoaderSubAssembler.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/event/loaders/NoteEventLoaderSubAssembler.kt index 9ff15e6407..172ba4d101 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/loaders/NoteEventLoaderSubAssembler.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/event/loaders/NoteEventLoaderSubAssembler.kt @@ -18,21 +18,28 @@ * 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.reqCommand.event.loaders +package com.vitorpamplona.amethyst.commons.relayClient.event.loaders -import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.SingleSubNoEoseCacheEoseManager -import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.EventFinderQueryState +import com.vitorpamplona.amethyst.commons.model.cache.ICacheProvider +import com.vitorpamplona.amethyst.commons.relayClient.eoseManagers.SingleSubNoEoseCacheEoseManager +import com.vitorpamplona.amethyst.commons.relayClient.event.EventFinderQueryState import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient class NoteEventLoaderSubAssembler( client: INostrClient, + val cache: ICacheProvider, allKeys: () -> Set, ) : SingleSubNoEoseCacheEoseManager(client, allKeys, invalidateAfterEose = true) { override fun updateFilter(keys: List) = listOfNotNull( - filterMissingEvents(keys), - filterMissingAddressables(keys), + filterMissingEvents(cache, keys), + filterMissingAddressables(cache, keys), ).flatten() override fun distinct(key: EventFinderQueryState) = key.note + + // Attribute to the account that owns this subscription, when a single account is watching. + // Deduped by pubkey hex, not by account identity, so two objects for the same logged-in user + // don't look like two accounts and suppress attribution. + override fun accountPubKeyOf(key: Any?): String? = (key as? EventFinderQueryState)?.account?.userFinderPubkeyHex } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/watchers/EventWatcherSubAssembler.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/event/watchers/EventWatcherSubAssembler.kt similarity index 89% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/watchers/EventWatcherSubAssembler.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/event/watchers/EventWatcherSubAssembler.kt index e58b596d2d..25a9bb3b5b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/watchers/EventWatcherSubAssembler.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/event/watchers/EventWatcherSubAssembler.kt @@ -18,15 +18,15 @@ * 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.reqCommand.event.watchers +package com.vitorpamplona.amethyst.commons.relayClient.event.watchers +import com.vitorpamplona.amethyst.commons.model.AddressableNote +import com.vitorpamplona.amethyst.commons.model.Note import com.vitorpamplona.amethyst.commons.relayClient.eoseManagers.SingleSubEoseManager -import com.vitorpamplona.amethyst.model.AddressableNote -import com.vitorpamplona.amethyst.model.Note -import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.EventFinderQueryState -import com.vitorpamplona.amethyst.service.relays.EOSEAccountFast -import com.vitorpamplona.amethyst.service.relays.MutableTime -import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.amethyst.commons.relayClient.event.EventFinderQueryState +import com.vitorpamplona.amethyst.commons.relays.EOSEAccountFast +import com.vitorpamplona.amethyst.commons.relays.MutableTime +import com.vitorpamplona.amethyst.commons.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter @@ -67,7 +67,7 @@ class EventWatcherSubAssembler( // the same logged-in user would look like two accounts and suppress attribution entirely. val soleAccountPubKey = keys - .mapTo(mutableSetOf()) { it.account.userProfile().pubkeyHex } + .mapTo(mutableSetOf()) { it.account.userFinderPubkeyHex } .singleOrNull() return groupByRelayPresence(lastNotesOnFilter, latestEOSEs) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/watchers/FilterRepliesAndReactionsToAddresses.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/event/watchers/FilterRepliesAndReactionsToAddresses.kt similarity index 96% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/watchers/FilterRepliesAndReactionsToAddresses.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/event/watchers/FilterRepliesAndReactionsToAddresses.kt index cce973f915..16e0f9f435 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/watchers/FilterRepliesAndReactionsToAddresses.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/event/watchers/FilterRepliesAndReactionsToAddresses.kt @@ -18,12 +18,12 @@ * 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.reqCommand.event.watchers +package com.vitorpamplona.amethyst.commons.relayClient.event.watchers +import com.vitorpamplona.amethyst.commons.model.AddressableNote import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose -import com.vitorpamplona.amethyst.model.AddressableNote -import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.amethyst.commons.relays.SincePerRelayMap import com.vitorpamplona.quartz.experimental.attestations.attestation.AttestationEvent import com.vitorpamplona.quartz.experimental.zapPolls.ZapPollEvent import com.vitorpamplona.quartz.nip01Core.core.HexKey diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/watchers/FilterRepliesAndReactionsToNotes.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/event/watchers/FilterRepliesAndReactionsToNotes.kt similarity index 96% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/watchers/FilterRepliesAndReactionsToNotes.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/event/watchers/FilterRepliesAndReactionsToNotes.kt index 64510793f8..f96a5b46fb 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/watchers/FilterRepliesAndReactionsToNotes.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/event/watchers/FilterRepliesAndReactionsToNotes.kt @@ -20,12 +20,12 @@ */ @file:Suppress("DEPRECATION") -package com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.watchers +package com.vitorpamplona.amethyst.commons.relayClient.event.watchers +import com.vitorpamplona.amethyst.commons.model.Note import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose -import com.vitorpamplona.amethyst.model.Note -import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.amethyst.commons.relays.SincePerRelayMap import com.vitorpamplona.quartz.experimental.attestations.attestation.AttestationEvent import com.vitorpamplona.quartz.experimental.edits.TextNoteModificationEvent import com.vitorpamplona.quartz.experimental.zapPolls.ZapPollEvent diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/user/PickRelaysToLoadUsers.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/user/PickRelaysToLoadUsers.kt new file mode 100644 index 0000000000..289a2c96bf --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/user/PickRelaysToLoadUsers.kt @@ -0,0 +1,147 @@ +/* + * 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.relayClient.user + +import com.vitorpamplona.amethyst.commons.model.User +import com.vitorpamplona.amethyst.commons.relays.EOSEAccountFast +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.hints.HintIndexer +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.utils.mapOfSet + +fun pickRelaysToLoadUsers( + users: Set, + relayHints: HintIndexer, + indexRelays: Set, + homeRelays: Set, + searchRelays: Set, + connected: Set, + commonRelays: Set, + cannotConnectRelays: Set, + hasTried: EOSEAccountFast, +): Map> = + mapOfSet { + users.forEachIndexed { _, key -> + val tried = (hasTried.since(key)?.keys ?: emptySet()) + cannotConnectRelays + + val outbox = key.authorRelayList()?.writeRelaysNorm() + + if (!outbox.isNullOrEmpty()) { + // If there is a home, get from it. + + // if it tried all outbox relays, stop. + // the UserWatch will take over from here. + val leftToTry = (outbox - tried) + leftToTry.forEach { + add(it, key.pubkeyHex) + } + } else { + // if not, tries hints first. + val hints = key.allUsedRelays() + relayHints.hintsForKey(key.pubkeyHex) + + val leftToTryOnHints = hints - tried + + leftToTryOnHints.forEach { + add(it, key.pubkeyHex) + } + + // if there are only a few hints, broadens the search + if (leftToTryOnHints.size < 3) { + // This creates a pre-deterministic order of the array such that + // if this function is called twice, it returns the same arrays + // which gets ignored by the relay client if we send it twice + val indexRelaysLeftToTry = + (indexRelays - tried).sortedBy { relay -> + key.pubkeyHex.hashCode() xor relay.url.hashCode() + } + // This creates a pre-deterministic order of the array such that + // if this function is called twice, it returns the same arrays + // which gets ignored by the relay client if we send it twice + val homeRelaysLeftToTry = + (homeRelays - tried).sortedBy { relay -> + key.pubkeyHex.hashCode() xor relay.url.hashCode() + } + + // picks one at random to avoid overloading these relays + if (users.size > 300) { + if (indexRelaysLeftToTry.size >= 2) { + add(indexRelaysLeftToTry[0], key.pubkeyHex) + add(indexRelaysLeftToTry[1], key.pubkeyHex) + } else if (indexRelaysLeftToTry.size == 1) { + add(indexRelaysLeftToTry.first(), key.pubkeyHex) + } + + homeRelaysLeftToTry.forEach { + add(it, key.pubkeyHex) + } + } else { + indexRelaysLeftToTry.forEach { + add(it, key.pubkeyHex) + } + + homeRelaysLeftToTry.forEach { + add(it, key.pubkeyHex) + } + } + + if (indexRelaysLeftToTry.size < 2) { + val searchRelaysLeftToTry = searchRelays - tried + + searchRelaysLeftToTry.forEach { + add(it, key.pubkeyHex) + } + + val connectedRelaysLeftToTry = + (connected - tried) + .sortedBy { relay -> + key.pubkeyHex.hashCode() xor relay.url.hashCode() + }.take(100) + + // picks one at random to avoid overloading these relays + if (users.size > 300) { + connectedRelaysLeftToTry.take(20).forEach { + add(it, key.pubkeyHex) + } + } else { + connectedRelaysLeftToTry.forEach { + add(it, key.pubkeyHex) + } + } + + if (searchRelaysLeftToTry.size < 2) { + // This creates a pre-deterministic order of the array such that + // if this function is called twice, it returns the same arrays + // which gets ignored by the relay client if we send it twice + val allRelaysLeftToTry = + (commonRelays - tried) + .sortedBy { relay -> + key.pubkeyHex.hashCode() xor relay.url.hashCode() + }.take(100) + + allRelaysLeftToTry.forEach { + add(it, key.pubkeyHex) + } + } + } + } + } + } + } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/user/UserFinderAccount.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/user/UserFinderAccount.kt new file mode 100644 index 0000000000..d1ecaae910 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/user/UserFinderAccount.kt @@ -0,0 +1,99 @@ +/* + * 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.relayClient.user + +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip85TrustedAssertions.list.tags.ServiceProviderTag + +/** + * Narrow, read-only view of a logged-in account that the user-finder + * subscription layer needs to route metadata / relay-list / report / + * contact-card REQs for *other* users. + * + * This is deliberately NOT part of [IAccount][com.vitorpamplona.amethyst.commons.model.IAccount]: + * `IAccount` is a behavioral capability interface for the *acting* user + * (sending DMs, gift wraps, MLS groups). The relay hints needed to *discover + * other users' metadata* are a separate concern, so they live on their own + * narrow interface (ISP). + * + * All accessors are **snapshot getters** read fresh on every filter rebuild — + * matching the prior direct `account.xxx.flow.value` reads. Relay-list changes + * therefore take effect on the next subscription invalidation without any + * captured-snapshot staleness. + * + * Platforms implement this on their concrete account (Android `Account`, + * Desktop `DesktopIAccount`). Fields with no backing on a platform degrade + * safely: Desktop has no NIP-85 trust-provider subsystem wired, so + * [trustProvider] returns null and [declaredFollowsByOutboxRelay] returns an + * empty map — contact-card and report discovery become best-effort there. + */ +interface UserFinderAccount { + /** This account's own pubkey (hex). */ + val userFinderPubkeyHex: HexKey + + /** Index/discovery relays, with the platform default fallback already applied. */ + fun indexRelays(): Set + + /** Home/write relays used for outbox discovery (nip65 + private storage + local). */ + fun outboxHomeRelays(): Set + + /** Search relays (trusted + own search list), for the user-finder's search-tier fallback. */ + fun searchRelays(): Set + + /** + * Just this account's own NIP-51 search relay list — WITHOUT the trusted-relay + * union that [searchRelays] adds. This is the narrow set the per-note event + * finder fans "missing event" REQs to, matching the pre-extraction + * `account.searchRelayList` read (reusing [searchRelays] there would have + * unintentionally widened the fan-out to trusted relays). + */ + fun searchOnlyRelays(): Set + + /** + * Follow + all-mine + search relays, used by the per-note event-finder to + * place "missing event" / "missing addressable" REQs (reactions, zaps, + * reposts, replies) when a note references content no relay has yet placed. + * Snapshot getter, same contract as the others. + */ + fun followPlusAllMineWithSearchRelays(): Set + + /** Shared-outbox / proxy relays used as the broad common fallback. */ + fun commonRelays(): Set + + /** Home relays used specifically for NIP-51 contact-card (kind 30382) discovery. */ + fun cardHomeRelays(): Set + + /** NIP-85 trusted-assertions rank provider, or null when unsupported (e.g. Desktop). */ + fun trustProvider(): ServiceProviderTag? + + /** + * NIP-85 follower-count rank provider, or null when unsupported (e.g. Desktop). + * Read by the contact-card sub-assembler alongside [trustProvider]. + */ + fun followerCountProvider(): ServiceProviderTag? + + /** + * Declared follows keyed by the relay they were declared on, used to trust + * report authors. Empty when the platform has no follow-graph-per-relay data. + */ + fun declaredFollowsByOutboxRelay(): Map> +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/UserFinderFilterAssembler.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/user/UserFinderFilterAssembler.kt similarity index 74% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/UserFinderFilterAssembler.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/user/UserFinderFilterAssembler.kt index b0383beda5..9e92c5213a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/UserFinderFilterAssembler.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/user/UserFinderFilterAssembler.kt @@ -18,18 +18,16 @@ * 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.reqCommand.user +package com.vitorpamplona.amethyst.commons.relayClient.user import androidx.compose.runtime.Stable +import com.vitorpamplona.amethyst.commons.model.User +import com.vitorpamplona.amethyst.commons.model.cache.ICacheProvider import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager -import com.vitorpamplona.amethyst.model.Account -import com.vitorpamplona.amethyst.model.LocalCache -import com.vitorpamplona.amethyst.model.User -import com.vitorpamplona.amethyst.service.relayClient.AccountScopedQuery -import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.loaders.UserOutboxFinderSubAssembler -import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.watchers.UserCardsSubAssembler -import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.watchers.UserReportsSubAssembler -import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.watchers.UserWatcherSubAssembler +import com.vitorpamplona.amethyst.commons.relayClient.user.loaders.UserOutboxFinderSubAssembler +import com.vitorpamplona.amethyst.commons.relayClient.user.watchers.UserCardsSubAssembler +import com.vitorpamplona.amethyst.commons.relayClient.user.watchers.UserReportsSubAssembler +import com.vitorpamplona.amethyst.commons.relayClient.user.watchers.UserWatcherSubAssembler import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.RelayOfflineTracker @@ -37,13 +35,13 @@ import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.RelayOfflineT @Stable class UserFinderQueryState( val user: User, - override val account: Account, -) : AccountScopedQuery + val account: UserFinderAccount, +) @Stable class UserFinderFilterAssembler( client: INostrClient, - cache: LocalCache, + cache: ICacheProvider, failureTracker: RelayOfflineTracker, ) : ComposeSubscriptionManager() { val group = diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/user/UserFinderSubscription.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/user/UserFinderSubscription.kt new file mode 100644 index 0000000000..51dfc57ea3 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/user/UserFinderSubscription.kt @@ -0,0 +1,85 @@ +/* + * 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.relayClient.user + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.runtime.staticCompositionLocalOf +import com.vitorpamplona.amethyst.commons.model.User +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.LifecycleAwareKeyDataSourceSubscription + +/** + * The shared per-user metadata data source for the current front end. A front + * end provides this once near its composition root (Android via AppModules, + * Desktop via its subscriptions coordinator). Reading it without a provider is + * a programming error — the `observeUser*` composables must never be reachable + * from a composition that has no relay client (e.g. the Android `:napplet` + * sandbox process). + */ +val LocalUserFinder = + staticCompositionLocalOf { + error("LocalUserFinder not provided") + } + +/** + * The current logged-in account, in the narrow [UserFinderAccount] view the + * finder needs to route REQs. Provided alongside [LocalUserFinder]. + */ +val LocalUserFinderAccount = + staticCompositionLocalOf { + error("LocalUserFinderAccount not provided") + } + +/** + * Subscribes to relay updates for [user]'s metadata (and relay list / reports / + * contact cards) for as long as this composable is in composition, coalesced + * with every other on-screen user into batched REQs by [dataSource]. + * + * Because a `LazyColumn` composes only the visible window (+ a small prefetch + * buffer), this naturally means "load metadata only for users currently on + * screen" — the [LifecycleAwareKeyDataSourceSubscription] unsubscribes ~30s + * after the row leaves composition or the app is backgrounded. + */ +@Composable +fun UserFinderFilterAssemblerSubscription( + user: User, + account: UserFinderAccount, + dataSource: UserFinderFilterAssembler, +) { + // Different screens get their own query-state instance even when tracking + // the same user; the assembler dedups to one REQ per pubkey. + val state = remember(user, account) { UserFinderQueryState(user, account) } + + LifecycleAwareKeyDataSourceSubscription(state, dataSource) +} + +/** + * Convenience overload that reads the front end's [LocalUserFinder] and + * [LocalUserFinderAccount] from the composition. + */ +@Composable +fun UserFinderFilterAssemblerSubscription(user: User) { + UserFinderFilterAssemblerSubscription( + user = user, + account = LocalUserFinderAccount.current, + dataSource = LocalUserFinder.current, + ) +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/user/UserMetadataObservers.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/user/UserMetadataObservers.kt new file mode 100644 index 0000000000..c1d20a41d9 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/user/UserMetadataObservers.kt @@ -0,0 +1,177 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.relayClient.user + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.State +import androidx.compose.runtime.remember +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.vitorpamplona.amethyst.commons.model.User +import com.vitorpamplona.amethyst.commons.model.nip01Core.UserInfo +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.map + +/** + * Shared, platform-agnostic observers for a single user's metadata (kind 0). + * + * Each observer both (a) opens a composition-scoped relay subscription for the + * user via [UserFinderFilterAssemblerSubscription] — so metadata is fetched only + * while the user is on screen — and (b) collects the resulting cache flow so the + * UI recomposes when the metadata arrives. The `(user)` overloads read the + * front end's [LocalUserFinder] / [LocalUserFinderAccount]; the explicit-param + * overloads are for callers that already hold both (and for tests). + * + * These are metadata-only. Richer per-user observers that depend on account + * subsystems not yet in commons (contact-card petnames, follow counts, + * bookmarks, statuses) remain in the Android layer for now and layer on top of + * the same subscription. + */ +@Composable +fun observeUserInfo( + user: User, + userFinder: UserFinderFilterAssembler, + account: UserFinderAccount, +): State { + UserFinderFilterAssemblerSubscription(user, account, userFinder) + return user.metadata().flow.collectAsStateWithLifecycle() +} + +@Composable +fun observeUserInfo(user: User): State = observeUserInfo(user, LocalUserFinder.current, LocalUserFinderAccount.current) + +@Composable +fun observeUserPicture( + user: User, + userFinder: UserFinderFilterAssembler, + account: UserFinderAccount, +): State { + UserFinderFilterAssemblerSubscription(user, account, userFinder) + + val flow = + remember(user) { + user + .metadata() + .flow + .map { it?.info?.picture } + .distinctUntilChanged() + } + + return flow.collectAsStateWithLifecycle( + user + .metadataOrNull() + ?.flow + ?.value + ?.info + ?.picture, + ) +} + +@Composable +fun observeUserPicture(user: User): State = observeUserPicture(user, LocalUserFinder.current, LocalUserFinderAccount.current) + +@Composable +fun observeUserBanner( + user: User, + userFinder: UserFinderFilterAssembler, + account: UserFinderAccount, +): State { + UserFinderFilterAssemblerSubscription(user, account, userFinder) + + val flow = + remember(user) { + user + .metadata() + .flow + .map { it?.info?.banner } + .distinctUntilChanged() + } + + return flow.collectAsStateWithLifecycle( + user + .metadataOrNull() + ?.flow + ?.value + ?.info + ?.banner, + ) +} + +@Composable +fun observeUserBanner(user: User): State = observeUserBanner(user, LocalUserFinder.current, LocalUserFinderAccount.current) + +@Composable +fun observeUserAboutMe( + user: User, + userFinder: UserFinderFilterAssembler, + account: UserFinderAccount, +): State { + UserFinderFilterAssemblerSubscription(user, account, userFinder) + + val flow = + remember(user) { + user + .metadata() + .flow + .map { it?.info?.about ?: "" } + .distinctUntilChanged() + } + + return flow.collectAsStateWithLifecycle( + user + .metadataOrNull() + ?.flow + ?.value + ?.info + ?.about ?: "", + ) +} + +@Composable +fun observeUserAboutMe(user: User): State = observeUserAboutMe(user, LocalUserFinder.current, LocalUserFinderAccount.current) + +/** + * The user's best available display name from their own metadata (kind 0), + * falling back to a truncated pubkey. Metadata-only: it does NOT apply the + * viewing account's private contact-card petname (that stays in the Android + * layer, which wraps this). + */ +@Composable +fun observeUserName( + user: User, + userFinder: UserFinderFilterAssembler, + account: UserFinderAccount, +): State { + UserFinderFilterAssemblerSubscription(user, account, userFinder) + + val flow = + remember(user) { + user + .metadata() + .flow + .map { it?.info?.bestName() ?: user.toBestDisplayName() } + .distinctUntilChanged() + } + + return flow.collectAsStateWithLifecycle(user.toBestDisplayName()) +} + +@Composable +fun observeUserName(user: User): State = observeUserName(user, LocalUserFinder.current, LocalUserFinderAccount.current) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/loaders/UserOutboxFinderSubAssembler.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/user/loaders/UserOutboxFinderSubAssembler.kt similarity index 81% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/loaders/UserOutboxFinderSubAssembler.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/user/loaders/UserOutboxFinderSubAssembler.kt index 7743fb7c7c..0243f98f69 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/loaders/UserOutboxFinderSubAssembler.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/user/loaders/UserOutboxFinderSubAssembler.kt @@ -18,18 +18,18 @@ * 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.reqCommand.user.loaders +package com.vitorpamplona.amethyst.commons.relayClient.user.loaders import com.vitorpamplona.amethyst.commons.defaults.DefaultIndexerRelayList import com.vitorpamplona.amethyst.commons.defaults.DefaultSearchRelayList +import com.vitorpamplona.amethyst.commons.model.User +import com.vitorpamplona.amethyst.commons.model.cache.ICacheProvider import com.vitorpamplona.amethyst.commons.relayClient.eoseManagers.BaseEoseManager import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose -import com.vitorpamplona.amethyst.model.LocalCache -import com.vitorpamplona.amethyst.model.User -import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.follows.pickRelaysToLoadUsers -import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.UserFinderQueryState -import com.vitorpamplona.amethyst.service.relays.EOSEAccountFast +import com.vitorpamplona.amethyst.commons.relayClient.user.UserFinderQueryState +import com.vitorpamplona.amethyst.commons.relayClient.user.pickRelaysToLoadUsers +import com.vitorpamplona.amethyst.commons.relays.EOSEAccountFast import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient @@ -44,7 +44,7 @@ import com.vitorpamplona.quartz.utils.TimeUtils class UserOutboxFinderSubAssembler( client: INostrClient, - val cache: LocalCache, + val cache: ICacheProvider, val failureTracker: RelayOfflineTracker, allKeys: () -> Set, ) : BaseEoseManager(client, allKeys) { @@ -108,19 +108,37 @@ class UserOutboxFinderSubAssembler( // and the users being resolved are whoever is on screen rather than anyone's follow list — so // with several accounts active there is no single honest owner for a given filter, and // splitting the sweep per account would re-issue the same lookups once per account. - // Deduped by pubkey, not by `Account`: that class uses identity equality, so two objects for - // the same logged-in user would look like two accounts and suppress attribution entirely. + // Deduped by pubkey, not by account identity: two objects for the same logged-in user would + // look like two accounts and suppress attribution entirely. val soleAccountPubKey = accounts - .mapTo(mutableSetOf()) { it.userProfile().pubkeyHex } + .mapTo(mutableSetOf()) { it.userFinderPubkeyHex } .singleOrNull() + // Union of every asking account's relay tiers. The UserFinderAccount getters already apply the + // platform default fallbacks (index/search), matching the prior outer pickRelaysToLoadUsers. + val cannotConnect = failureTracker.cannotConnectRelays + val indexRelays = mutableSetOf() + val homeRelays = mutableSetOf() + val searchRelays = mutableSetOf() + val commonRelays = mutableSetOf() + accounts.forEach { account -> + indexRelays.addAll(account.indexRelays()) + homeRelays.addAll(account.outboxHomeRelays()) + searchRelays.addAll(account.searchRelays()) + commonRelays.addAll(account.commonRelays()) + } + val perRelayKeysBoth = pickRelaysToLoadUsers( noOutboxList, - accounts, + cache.relayHints, + indexRelays - cannotConnect, + homeRelays - cannotConnect, + searchRelays - cannotConnect, connectedRelays, - failureTracker.cannotConnectRelays, + commonRelays - cannotConnect, + cannotConnect, hasTried, ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/watchers/FilterReportsToKey.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/user/watchers/FilterReportsToKey.kt similarity index 96% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/watchers/FilterReportsToKey.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/user/watchers/FilterReportsToKey.kt index a369d824e7..d8b069cfa3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/watchers/FilterReportsToKey.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/user/watchers/FilterReportsToKey.kt @@ -18,7 +18,7 @@ * 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.reqCommand.user.watchers +package com.vitorpamplona.amethyst.commons.relayClient.user.watchers import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/watchers/FilterUserMetadataForKey.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/user/watchers/FilterUserMetadataForKey.kt similarity index 94% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/watchers/FilterUserMetadataForKey.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/user/watchers/FilterUserMetadataForKey.kt index e52167f763..23227231ad 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/watchers/FilterUserMetadataForKey.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/user/watchers/FilterUserMetadataForKey.kt @@ -18,16 +18,16 @@ * 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.reqCommand.user.watchers +package com.vitorpamplona.amethyst.commons.relayClient.user.watchers +import com.vitorpamplona.amethyst.commons.model.User import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose -import com.vitorpamplona.amethyst.model.LocalCache -import com.vitorpamplona.amethyst.model.User -import com.vitorpamplona.amethyst.service.relays.EOSEAccountFast +import com.vitorpamplona.amethyst.commons.relays.EOSEAccountFast import com.vitorpamplona.quartz.experimental.nipA3.PaymentTargetsEvent import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageRelayListEvent import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.hints.HintIndexer import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl @@ -60,6 +60,7 @@ val UserMetadataForKeyKinds = fun filterUserMetadataForKey( authors: Set, + relayHints: HintIndexer, indexRelays: Set, cannotConnectRelays: Set, since: EOSEAccountFast, @@ -72,7 +73,7 @@ fun filterUserMetadataForKey( val relays = when { outbox == null -> - key.allUsedRelays() + LocalCache.relayHints.hintsForKey(key.pubkeyHex) + indexRelays + key.allUsedRelays() + relayHints.hintsForKey(key.pubkeyHex) + indexRelays // Outbox is published but exhausted (every relay either EOSE'd // or is known-unreachable) and metadata is still missing — // widen to indexers so a misconfigured outbox doesn't strand diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/watchers/UserCardsSubAssembler.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/user/watchers/UserCardsSubAssembler.kt similarity index 88% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/watchers/UserCardsSubAssembler.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/user/watchers/UserCardsSubAssembler.kt index 0404db46cb..9c4ffbc31e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/watchers/UserCardsSubAssembler.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/user/watchers/UserCardsSubAssembler.kt @@ -18,16 +18,16 @@ * 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.reqCommand.user.watchers +package com.vitorpamplona.amethyst.commons.relayClient.user.watchers +import com.vitorpamplona.amethyst.commons.model.User +import com.vitorpamplona.amethyst.commons.model.cache.ICacheProvider import com.vitorpamplona.amethyst.commons.model.toHexSet import com.vitorpamplona.amethyst.commons.relayClient.assemblers.filterContactCardsToTargetKeysFromTrustedAccountsInTheRelay import com.vitorpamplona.amethyst.commons.relayClient.eoseManagers.SingleSubEoseManager -import com.vitorpamplona.amethyst.model.LocalCache -import com.vitorpamplona.amethyst.model.User -import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.UserFinderQueryState -import com.vitorpamplona.amethyst.service.relays.MutableTime -import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.amethyst.commons.relayClient.user.UserFinderQueryState +import com.vitorpamplona.amethyst.commons.relays.MutableTime +import com.vitorpamplona.amethyst.commons.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter @@ -38,7 +38,7 @@ import com.vitorpamplona.quartz.utils.mapOfSet class UserCardsSubAssembler( client: INostrClient, - val cache: LocalCache, + val cache: ICacheProvider, allKeys: () -> Set, ) : SingleSubEoseManager(client, allKeys) { override fun newEose( @@ -74,22 +74,22 @@ class UserCardsSubAssembler( // accounts, so with several active none of them owns a given filter. val soleAccountPubKey = accounts - .mapTo(mutableSetOf()) { it.userProfile().pubkeyHex } + .mapTo(mutableSetOf()) { it.userFinderPubkeyHex } .singleOrNull() val trustedAccounts: Map> = mapOfSet { accounts.forEach { account -> - account.homeRelays.flow.value.forEach { - add(it, account.userProfile().pubkeyHex) + account.cardHomeRelays().forEach { + add(it, account.userFinderPubkeyHex) } } - accounts.map { it.trustProviderList.liveUserRankProvider.value }.forEach { provider -> + accounts.map { it.trustProvider() }.forEach { provider -> if (provider != null) { add(provider.relayUrl, provider.pubkey) } } - accounts.map { it.trustProviderList.liveUserFollowerCount.value }.forEach { provider -> + accounts.map { it.followerCountProvider() }.forEach { provider -> if (provider != null) { add(provider.relayUrl, provider.pubkey) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/watchers/UserReportsSubAssembler.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/user/watchers/UserReportsSubAssembler.kt similarity index 89% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/watchers/UserReportsSubAssembler.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/user/watchers/UserReportsSubAssembler.kt index 4371a4f822..5731d42b64 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/watchers/UserReportsSubAssembler.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/user/watchers/UserReportsSubAssembler.kt @@ -18,16 +18,16 @@ * 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.reqCommand.user.watchers +package com.vitorpamplona.amethyst.commons.relayClient.user.watchers +import com.vitorpamplona.amethyst.commons.model.User +import com.vitorpamplona.amethyst.commons.model.cache.ICacheProvider import com.vitorpamplona.amethyst.commons.model.toHexSet import com.vitorpamplona.amethyst.commons.relayClient.eoseManagers.SingleSubEoseManager -import com.vitorpamplona.amethyst.model.Account -import com.vitorpamplona.amethyst.model.LocalCache -import com.vitorpamplona.amethyst.model.User -import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.UserFinderQueryState -import com.vitorpamplona.amethyst.service.relays.MutableTime -import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.amethyst.commons.relayClient.user.UserFinderAccount +import com.vitorpamplona.amethyst.commons.relayClient.user.UserFinderQueryState +import com.vitorpamplona.amethyst.commons.relays.MutableTime +import com.vitorpamplona.amethyst.commons.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter @@ -35,7 +35,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl class UserReportsSubAssembler( client: INostrClient, - val cache: LocalCache, + val cache: ICacheProvider, allKeys: () -> Set, ) : SingleSubEoseManager(client, allKeys) { override fun newEose( @@ -72,12 +72,13 @@ class UserReportsSubAssembler( } private fun filtersFor( - account: Account, + account: UserFinderAccount, lastUsersOnFilter: Set, ): List { - val accountPubKey = account.userProfile().pubkeyHex + val accountPubKey = account.userFinderPubkeyHex - return account.declaredFollowsPerOutboxRelay.value + return account + .declaredFollowsByOutboxRelay() .flatMap { (relay, trustedUsersInThisRelay) -> // this relay + accounts are where we could find reports. // we might have already loaded them, so let's separate new targets that were checked before from the others diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/watchers/UserWatcherSubAssembler.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/user/watchers/UserWatcherSubAssembler.kt similarity index 87% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/watchers/UserWatcherSubAssembler.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/user/watchers/UserWatcherSubAssembler.kt index f5381a9be1..673b71a977 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/watchers/UserWatcherSubAssembler.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/user/watchers/UserWatcherSubAssembler.kt @@ -18,14 +18,13 @@ * 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.reqCommand.user.watchers +package com.vitorpamplona.amethyst.commons.relayClient.user.watchers -import com.vitorpamplona.amethyst.commons.defaults.DefaultIndexerRelayList +import com.vitorpamplona.amethyst.commons.model.User +import com.vitorpamplona.amethyst.commons.model.cache.ICacheProvider import com.vitorpamplona.amethyst.commons.relayClient.eoseManagers.BaseEoseManager -import com.vitorpamplona.amethyst.model.LocalCache -import com.vitorpamplona.amethyst.model.User -import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.UserFinderQueryState -import com.vitorpamplona.amethyst.service.relays.EOSEAccountFast +import com.vitorpamplona.amethyst.commons.relayClient.user.UserFinderQueryState +import com.vitorpamplona.amethyst.commons.relays.EOSEAccountFast import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.RelayOfflineTracker @@ -37,7 +36,7 @@ import com.vitorpamplona.quartz.utils.TimeUtils class UserWatcherSubAssembler( client: INostrClient, - val cache: LocalCache, + val cache: ICacheProvider, val failureTracker: RelayOfflineTracker, allKeys: () -> Set, ) : BaseEoseManager(client, allKeys) { @@ -100,20 +99,18 @@ class UserWatcherSubAssembler( // account and the users are whoever is on screen, so with several askers none of them owns it. val soleAccountPubKey = keys - .mapTo(mutableSetOf()) { it.account.userProfile().pubkeyHex } + .mapTo(mutableSetOf()) { it.account.userFinderPubkeyHex } .singleOrNull() val indexRelays = mutableSetOf() keys.mapTo(mutableSetOf()) { it.account }.forEach { - indexRelays.addAll( - it.indexerRelayList.flow.value - .ifEmpty { DefaultIndexerRelayList }, - ) + indexRelays.addAll(it.indexRelays()) } val newFilters = filterUserMetadataForKey( users, + cache.relayHints, indexRelays, failureTracker.cannotConnectRelays, latestEOSEs, diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relays/EOSEAccountFast.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relays/EOSEAccountFast.kt new file mode 100644 index 0000000000..84d5e90773 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relays/EOSEAccountFast.kt @@ -0,0 +1,94 @@ +/* + * 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.relays + +import androidx.collection.LruCache +import com.vitorpamplona.amethyst.commons.util.KmpLock +import com.vitorpamplona.amethyst.commons.util.withLock +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl + +/** + * Per-key EOSE tracker keyed by an arbitrary [T] (a `User`, a pubkey, …), used + * by the relay-subscription assemblers to remember which relays already EOSE'd + * for a given key so the next filter assembly can add a `since` and avoid a full + * re-download. + * + * KMP-pure: uses [KmpLock] instead of `synchronized(...)` so it compiles for the + * iOS targets `commons` builds for. Moved out of `amethyst.service.relays` so the + * shared user/event finder assemblers can live in `commonMain`. The old location + * keeps a `typealias` for source compatibility. + */ +class EOSEAccountFast( + cacheSize: Int = 20, +) { + private val users: LruCache = LruCache(cacheSize) + private val lock = KmpLock() + + fun addOrUpdate( + user: T, + relayUrl: NormalizedRelayUrl, + time: Long, + ) { + lock.withLock { + val relayList = users[user] + if (relayList == null) { + val newList = EOSERelayList() + users.put(user, newList) + + newList.addOrUpdate(relayUrl, time) + } else { + relayList.addOrUpdate(relayUrl, time) + } + } + } + + fun removeEveryoneBut(list: Set) { + lock.withLock { + users.snapshot().forEach { + if (it.key !in list) { + users.remove(it.key) + } + } + } + } + + fun removeDataFor(user: T) { + lock.withLock { + users.remove(user) + } + } + + fun since(key: T): SincePerRelayMap? = + lock.withLock { + users[key]?.relayList?.toMutableMap() + } + + fun sinceRelaySet(key: T): Set? = + lock.withLock { + users[key]?.relayList?.keys?.toSet() + } + + fun newEose( + user: T, + relayUrl: NormalizedRelayUrl, + time: Long, + ) = addOrUpdate(user, relayUrl, time) +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/UserSearchCard.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/UserSearchCard.kt index bd7e82f54f..4badbacef1 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/UserSearchCard.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/UserSearchCard.kt @@ -32,6 +32,7 @@ import androidx.compose.material3.CardDefaults import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.text.font.FontFamily @@ -39,6 +40,7 @@ import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.commons.model.User +import com.vitorpamplona.amethyst.commons.relayClient.user.observeUserInfo import com.vitorpamplona.amethyst.commons.resources.Res import com.vitorpamplona.amethyst.commons.resources.accessibility_navigate import com.vitorpamplona.amethyst.commons.resources.accessibility_user_avatar @@ -51,6 +53,12 @@ import org.jetbrains.compose.resources.stringResource * @param badge Optional overlay drawn on top of the avatar (bottom-right * by convention). Used by Desktop for the WoT trust-score chip; Android * call sites leave it null. Forwarded to [UserAvatar]. + * + * Loads the user's kind-0 metadata only while the card is composed via + * [observeUserInfo], so a search-results list only fetches metadata for the + * users currently on screen (coalesced into the shared finder's batched REQs). + * Requires [LocalUserFinder]/[LocalUserFinderAccount] in scope — provided at + * the Desktop logged-in roots; this card is Desktop-only. */ @Composable fun UserSearchCard( @@ -59,6 +67,8 @@ fun UserSearchCard( modifier: Modifier = Modifier, badge: @Composable (BoxScope.() -> Unit)? = null, ) { + val metadata by observeUserInfo(user) + Card( modifier = modifier @@ -76,7 +86,7 @@ fun UserSearchCard( ) { UserAvatar( userHex = user.pubkeyHex, - pictureUrl = user.profilePicture(), + pictureUrl = metadata?.info?.picture ?: user.profilePicture(), size = 40.dp, contentDescription = stringResource(Res.string.accessibility_user_avatar), badge = badge, @@ -84,11 +94,11 @@ fun UserSearchCard( Column(modifier = Modifier.weight(1f)) { Text( - user.toBestDisplayName(), + metadata?.info?.bestName() ?: user.toBestDisplayName(), style = MaterialTheme.typography.titleSmall, color = MaterialTheme.colorScheme.onSurface, ) - val nip05 = user.metadataOrNull()?.nip05() + val nip05 = metadata?.info?.nip05 ?: user.metadataOrNull()?.nip05() if (nip05 != null) { Text( nip05, diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordActionsTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordActionsTest.kt index a5d8ff772d..82169a7708 100644 --- a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordActionsTest.kt +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordActionsTest.kt @@ -123,6 +123,7 @@ class ConcordActionsTest { // Only the owner is staff, so only the owner's blob carries the secret. staffXOnly = setOf(owner.pubKey), createdAt = 5L, + ownerPubKey = owner.pubKey, ) val baseRekey = ConcordActions.nextBaseRekeyPlane(community.communityRoot, community.communityId, community.rootEpoch) diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/ThreadAssemblerTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/ThreadAssemblerTest.kt index e2a25246a9..3bbc6bbc5d 100644 --- a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/ThreadAssemblerTest.kt +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/ThreadAssemblerTest.kt @@ -25,6 +25,7 @@ import com.vitorpamplona.amethyst.commons.model.cache.ICacheProvider import com.vitorpamplona.quartz.nip01Core.core.Address import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.hints.HintIndexer import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent import com.vitorpamplona.quartz.nip22Comments.CommentEvent import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent @@ -173,6 +174,8 @@ class ThreadAssemblerTest { ) : ICacheProvider { override fun getAnyChannel(note: Note): Channel? = null + override val relayHints = HintIndexer() + override fun getUserIfExists(pubkey: HexKey): User? = null override fun countUsers(predicate: (String, User) -> Boolean): Int = 0 diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordBannedTypingTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordBannedTypingTest.kt new file mode 100644 index 0000000000..a89e26789e --- /dev/null +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordBannedTypingTest.kt @@ -0,0 +1,113 @@ +/* + * 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.concord + +import com.vitorpamplona.amethyst.commons.actions.ConcordActions +import com.vitorpamplona.amethyst.commons.actions.ConcordModeration +import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityFactory +import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEntry +import com.vitorpamplona.quartz.concord.cord02Community.NewConcordCommunity +import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal +import com.vitorpamplona.quartz.utils.TimeUtils +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * The receive half of the soft-ban audit's A4: a banned member's typing heartbeat must not reach + * the "… is typing" row. The send half is a guard in the app's own action layer, which a malicious + * or modified client simply won't run — so this filter, on the receive side, is the only one that + * actually protects the room. It shipped without a test; this is it. + */ +class ConcordBannedTypingTest { + private val owner = NostrSignerInternal(KeyPair()) + private val troll = NostrSignerInternal(KeyPair()) + private val regular = NostrSignerInternal(KeyPair()) + + private fun entryFor(community: NewConcordCommunity) = + ConcordCommunityListEntry( + id = community.communityIdHex, + owner = community.ownerPubKey, + ownerSalt = community.ownerSalt.toHexKey(), + root = community.communityRoot.toHexKey(), + rootEpoch = community.rootEpoch, + controlPk = community.controlPkHex, + controlRoot = community.controlRoot.toHexKey(), + relays = listOf("wss://r.example"), + name = "Nostrichs", + ) + + @Test + fun dropsABannedMembersTypingHeartbeatAndKeepsEveryoneElses() = + runTest { + val community = ConcordCommunityFactory.create(owner, "Nostrichs", createdAt = 1L, relays = listOf("wss://r.example")) + val session = ConcordCommunitySession(entryFor(community), owner.pubKey) + community.genesisWraps.forEach { session.ingest(it) } + + val channelId = community.generalChannelIdHex + val plane = ConcordActions.publicChannel(community.communityRoot, community.generalChannelId, community.rootEpoch) + val now = TimeUtils.now() + + // Ban first, so what follows tests the filter rather than an entry seated before the ban. + // The banlist edition folds through the Control Plane exactly as it would on the wire. + session.ingest( + ConcordModeration.ban( + actor = owner, + controlPlane = session.controlPlaneKeys(), + communityId = community.communityIdHex.hexToByteArray(), + member = troll.pubKey, + current = session.controlEditions(), + createdAt = now, + owner = community.ownerPubKey, + ), + ) + assertTrue( + session.state.value + ?.authority + ?.isBanned(troll.pubKey) == true, + "the ban must have folded before the heartbeats are judged", + ) + + // The banned member keeps broadcasting — a modified client ignores the send-side guard. + session.ingest(ConcordActions.buildChannelTyping(troll, plane, channelId, community.rootEpoch, now)) + assertEquals( + null, + session.typing.value[channelId]?.get(troll.pubKey.lowercase()), + "a banned member must never be seated in the typing row", + ) + + // The filter is targeted, not a blanket mute: an ordinary member still types normally. + session.ingest(ConcordActions.buildChannelTyping(regular, plane, channelId, community.rootEpoch, now)) + assertTrue( + session.typing.value[channelId]?.containsKey(regular.pubKey.lowercase()) == true, + "an unbanned member's typing heartbeat must still show", + ) + assertEquals( + null, + session.typing.value[channelId]?.get(troll.pubKey.lowercase()), + "seating one member must not drag the banned one in", + ) + } +} diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordChannelListLeaveTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordChannelListLeaveTest.kt index 600a56a38b..298295d60c 100644 --- a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordChannelListLeaveTest.kt +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordChannelListLeaveTest.kt @@ -33,6 +33,7 @@ import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.hints.HintIndexer import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -82,6 +83,8 @@ class ConcordChannelListLeaveTest { private class StubCache : ICacheProvider { override fun getAnyChannel(note: Note): Channel? = null + override val relayHints = HintIndexer() + override fun getUserIfExists(pubkey: HexKey): User? = null override fun countUsers(predicate: (String, User) -> Boolean): Int = 0 diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordListLateArrivalTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordListLateArrivalTest.kt index e4325e9280..6c682a88fc 100644 --- a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordListLateArrivalTest.kt +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordListLateArrivalTest.kt @@ -33,6 +33,7 @@ import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.hints.HintIndexer import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -80,6 +81,8 @@ class ConcordListLateArrivalTest { override fun getAnyChannel(note: Note): Channel? = null + override val relayHints = HintIndexer() + override fun getUserIfExists(pubkey: HexKey): User? = null override fun countUsers(predicate: (String, User) -> Boolean): Int = 0 diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordRollbackFloorTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordRollbackFloorTest.kt index d3c1a50a75..ae06a84891 100644 --- a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordRollbackFloorTest.kt +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordRollbackFloorTest.kt @@ -78,7 +78,7 @@ class ConcordRollbackFloorTest { // new epoch's plane is split and addressed by the derived signer, not the root. val newControlRoot = ByteArray(32) { 0x44 } val newControl = ControlPlaneKeys.forStaff(newRoot, community.communityId, newEpoch, newControlRoot) - val rolledBack = ConcordRefounding.compactControlPlane(community.genesisWraps, community.controlPlane, newControl) + val rolledBack = ConcordRefounding.compactControlPlane(community.genesisWraps, community.controlPlane, newControl, community.ownerPubKey) val entry = ConcordCommunityListEntry( @@ -145,7 +145,7 @@ class ConcordRollbackFloorTest { val newControlRoot = ByteArray(32) { 0x44 } val newControl = ControlPlaneKeys.forStaff(newRoot, community.communityId, newEpoch, newControlRoot) // Honest: compacted from the FULL prior plane, so each entity's head (metadata v1) survives. - val honest = ConcordRefounding.compactControlPlane(epoch0Wraps, community.controlPlane, newControl) + val honest = ConcordRefounding.compactControlPlane(epoch0Wraps, community.controlPlane, newControl, community.ownerPubKey) val entry = ConcordCommunityListEntry( @@ -186,7 +186,7 @@ class ConcordRollbackFloorTest { // new epoch's plane is split and addressed by the derived signer, not the root. val newControlRoot = ByteArray(32) { 0x44 } val newControl = ControlPlaneKeys.forStaff(newRoot, community.communityId, newEpoch, newControlRoot) - val compacted = ConcordRefounding.compactControlPlane(community.genesisWraps, community.controlPlane, newControl) + val compacted = ConcordRefounding.compactControlPlane(community.genesisWraps, community.controlPlane, newControl, community.ownerPubKey) val entry = ConcordCommunityListEntry( diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletArtifactPolicyTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletArtifactPolicyTest.kt new file mode 100644 index 0000000000..a079eeab6d --- /dev/null +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletArtifactPolicyTest.kt @@ -0,0 +1,54 @@ +/* + * 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.napplet + +import com.vitorpamplona.quartz.nip5aStaticWebsites.SiteAggregateHash +import com.vitorpamplona.quartz.nip5aStaticWebsites.tags.PathTag +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +class NappletArtifactPolicyTest { + private val htmlHash = "11".repeat(32) + private val index = PathTag("/index.html", htmlHash) + + @Test + fun computesIdentityFromTheSignedSingleIndexPath() { + assertEquals( + SiteAggregateHash.compute(listOf(index)), + NappletArtifactPolicy.verifiedAggregateHash(listOf(index), null), + ) + } + + @Test + fun acceptsMatchingDeclaredIdentityCaseInsensitively() { + val computed = SiteAggregateHash.compute(listOf(index)) + assertEquals(computed, NappletArtifactPolicy.verifiedAggregateHash(listOf(index), computed.uppercase())) + } + + @Test + fun rejectsDriftedOrNonSelfContainedArtifacts() { + assertNull(NappletArtifactPolicy.verifiedAggregateHash(listOf(index), "22".repeat(32))) + assertNull(NappletArtifactPolicy.verifiedAggregateHash(listOf(PathTag("index.html", htmlHash)), null)) + assertNull(NappletArtifactPolicy.verifiedAggregateHash(listOf(index, PathTag("/app.js", "22".repeat(32))), null)) + assertNull(NappletArtifactPolicy.verifiedAggregateHash(listOf(PathTag("/index.html", "not-a-sha256")), null)) + } +} diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletBrokerTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletBrokerTest.kt index 0b5bcca09f..fc59bb7f87 100644 --- a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletBrokerTest.kt +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletBrokerTest.kt @@ -182,9 +182,18 @@ class NappletBrokerTest { relay: NappletRelayGateway? = null, storage: NappletStorage? = null, wallet: NappletWalletGateway? = null, + resource: NappletResourceGateway? = null, signer: NostrSigner = this.signer, ledger: NappletPermissionLedger = NappletPermissionLedger(InMemoryNappletPermissionStore()), - ) = NappletBroker(signer, ledger, prompt, relay, storage, wallet) + ) = NappletBroker( + signer = signer, + ledger = ledger, + consentPrompt = prompt, + relay = relay, + storage = storage, + wallet = wallet, + resource = resource, + ) @Test fun getPublicKeyReturnsTheUsersKeyWhenAllowed() = @@ -545,8 +554,8 @@ class NappletBrokerTest { assertEquals(NappletResponse.Done, broker.handle(applet, NappletRequest.StorageSet("k", "v"), allDeclared)) assertEquals(NappletResponse.StorageValue("v"), broker.handle(applet, NappletRequest.StorageGet("k"), allDeclared)) - // Stored under the applet coordinate, never a shared namespace. - assertEquals("v", storage.data["${applet.coordinate}::k"]) + // Shared storage is scoped to the verified artifact identity, not just the d-tag. + assertEquals("v", storage.data["${applet.storageCoordinate}::k"]) assertEquals(NappletResponse.Done, broker.handle(applet, NappletRequest.StorageRemove("k"), allDeclared)) assertEquals(NappletResponse.StorageValue(null), broker.handle(applet, NappletRequest.StorageGet("k"), allDeclared)) @@ -563,7 +572,7 @@ class NappletBrokerTest { broker.handle(applet, NappletRequest.StorageSet("b", "2"), allDeclared) broker.handle(other, NappletRequest.StorageSet("c", "3"), allDeclared) - val response = broker.handle(applet, NappletRequest.StorageKeys, allDeclared) + val response = broker.handle(applet, NappletRequest.StorageKeys(), allDeclared) assertIs(response) assertEquals(setOf("a", "b"), response.values.toSet()) // never sees the other applet's "c" } @@ -643,19 +652,6 @@ class NappletBrokerTest { assertIs(response) } - @Test - fun shellSupportsReflectsDeclaredCapabilitiesWithoutConsent() = - runTest { - // The DENY prompt would block anything that reached consent; supports must not. - val broker = broker(ScriptedPrompt(GrantState.DENY)) - val declared = setOf(NappletCapability.RELAY) - - assertEquals(NappletResponse.Supported(true), broker.handle(applet, NappletRequest.ShellSupports("relay"), declared)) - assertEquals(NappletResponse.Supported(false), broker.handle(applet, NappletRequest.ShellSupports("storage"), declared)) - // Unknown/unbrokered domain. - assertEquals(NappletResponse.Supported(false), broker.handle(applet, NappletRequest.ShellSupports("cvm"), declared)) - } - @Test fun identityReadReturnsGatewayJsonOrUnsupported() = runTest { @@ -693,4 +689,60 @@ class NappletBrokerTest { assertIs(broker.handle(applet, NappletRequest.ResourceBytes("https://x"), allDeclared)) assertIs(broker.handle(applet, NappletRequest.UploadBlob(ByteArray(0), "image/png"), allDeclared)) } + + @Test + fun resourceInfoAndTypedFailuresFollowTheCurrentNapContract() = + runTest { + val resource = + NappletResourceGateway { _, _ -> + NappletResourceResult.Failure("blocked-by-policy", "private target") + } + val broker = broker(ScriptedPrompt(GrantState.ALLOW_ALWAYS), resource = resource) + + val info = broker.handle(applet, NappletRequest.ResourceInfo, allDeclared) + assertIs(info) + assertEquals(listOf("data", "https", "blossom", "nostr"), info.schemes) + assertEquals(10L * 1024L * 1024L, info.maxBytes) + + assertEquals( + NappletResponse.ResourceFailure("blocked-by-policy", "private target"), + broker.handle(applet, NappletRequest.ResourceBytes("https://internal.example"), allDeclared), + ) + } + + @Test + fun resourceBytesManyPreservesOrderAndSiblingResults() = + runTest { + val resource = + NappletResourceGateway { url, _ -> + if (url.endsWith("ok")) { + NappletResourceResult.Success(NappletResource("ok".encodeToByteArray(), "text/plain")) + } else { + NappletResourceResult.Failure("not-found") + } + } + val response = + broker(ScriptedPrompt(GrantState.ALLOW_ALWAYS), resource = resource) + .handle(applet, NappletRequest.ResourceBytesMany(listOf("https://x/ok", "https://x/missing")), allDeclared) + + assertIs(response) + assertEquals(listOf("https://x/ok", "https://x/missing"), response.items.map { it.url }) + assertIs(response.items[0].resource) + assertEquals("not-found", response.items[1].error) + } + + @Test + fun resourceBytesManyRejectsInvalidBulkSizesWithNapErrorCodes() = + runTest { + val resource = NappletResourceGateway { _, _ -> error("invalid bulk must not fetch") } + val broker = broker(ScriptedPrompt(GrantState.ALLOW_ALWAYS), resource = resource) + + val empty = broker.handle(applet, NappletRequest.ResourceBytesMany(emptyList()), allDeclared) + val tooLarge = broker.handle(applet, NappletRequest.ResourceBytesMany(List(17) { "data:,x" }), allDeclared) + + assertIs(empty) + assertEquals("invalid-request", empty.error) + assertIs(tooLarge) + assertEquals("too-large", tooLarge.error) + } } diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletCapabilityTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletCapabilityTest.kt index 4894ab6951..52cf819efd 100644 --- a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletCapabilityTest.kt +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletCapabilityTest.kt @@ -28,18 +28,12 @@ import kotlin.test.assertTrue class NappletCapabilityTest { @Test - fun mapsKnownDomainsCaseInsensitively() { - assertEquals(NappletCapability.SHELL, NappletCapability.fromNapDomain("shell")) + fun mapsOnlyConformingDomainsExactly() { assertEquals(NappletCapability.IDENTITY, NappletCapability.fromNapDomain("identity")) - assertEquals(NappletCapability.KEYS, NappletCapability.fromNapDomain("keys")) - assertEquals(NappletCapability.RELAY, NappletCapability.fromNapDomain("Relay")) - assertEquals(NappletCapability.VALUE, NappletCapability.fromNapDomain("value")) - assertEquals(NappletCapability.STORAGE, NappletCapability.fromNapDomain(" STORAGE ")) + assertEquals(NappletCapability.RELAY, NappletCapability.fromNapDomain("relay")) + assertEquals(NappletCapability.STORAGE, NappletCapability.fromNapDomain("storage")) assertEquals(NappletCapability.RESOURCE, NappletCapability.fromNapDomain("resource")) - assertEquals(NappletCapability.UPLOAD, NappletCapability.fromNapDomain("upload")) assertEquals(NappletCapability.THEME, NappletCapability.fromNapDomain("theme")) - assertEquals(NappletCapability.NOTIFY, NappletCapability.fromNapDomain("notify")) - assertEquals(NappletCapability.INC, NappletCapability.fromNapDomain("inc")) } @Test @@ -48,6 +42,14 @@ class NappletCapabilityTest { assertNull(NappletCapability.fromNapDomain("intent")) assertNull(NappletCapability.fromNapDomain("cvm")) assertNull(NappletCapability.fromNapDomain("filesystem")) + assertNull(NappletCapability.fromNapDomain("shell")) + assertNull(NappletCapability.fromNapDomain("keys")) + assertNull(NappletCapability.fromNapDomain("value")) + assertNull(NappletCapability.fromNapDomain("upload")) + assertNull(NappletCapability.fromNapDomain("notify")) + assertNull(NappletCapability.fromNapDomain("inc")) + assertNull(NappletCapability.fromNapDomain("Relay")) + assertNull(NappletCapability.fromNapDomain(" storage ")) assertNull(NappletCapability.fromNapDomain("")) } @@ -56,10 +58,10 @@ class NappletCapabilityTest { val resolved = resolveRequiredCapabilities(listOf("identity", "relay", "intent", "value")) assertEquals( - setOf(NappletCapability.IDENTITY, NappletCapability.RELAY, NappletCapability.VALUE), + setOf(NappletCapability.IDENTITY, NappletCapability.RELAY), resolved.capabilities, ) - assertEquals(listOf(UnknownNapDomain("intent")), resolved.unknown) + assertEquals(listOf(UnknownNapDomain("intent"), UnknownNapDomain("value")), resolved.unknown) assertTrue(resolved.hasUnknown) } diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletWebContractTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletWebContractTest.kt new file mode 100644 index 0000000000..656ebcc1a6 --- /dev/null +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletWebContractTest.kt @@ -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.commons.napplet + +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertContains +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class NappletWebContractTest { + @Test + fun lockedPreludeIsTheFirstHeadContentAndRunsBeforeAuthoredCode() { + val authored = "" + val injected = + NappletWebContract + .injectPrelude( + html = authored.encodeToByteArray(), + shimJs = "window.__shimRan=true;", + declaredDomains = listOf("identity", "relay", "shell", "Relay", "bad\"domain", "relay"), + locked = true, + ).decodeToString() + + val head = injected.indexOf("") + "".length + val csp = injected.indexOf(" Boolean): Int = 0 diff --git a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletRequestRouter.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletRequestRouter.kt index 2e4c2fe2e5..fb27dd611e 100644 --- a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletRequestRouter.kt +++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletRequestRouter.kt @@ -42,6 +42,7 @@ object NappletRequestRouter { /** Send this `.result` payload back, correlated to the request's id. */ data class Reply( val payload: String, + val response: NappletResponse? = null, ) : Outcome /** Open a live relay subscription; the host streams `relay.event`/`relay.eose`/`relay.closed` by [subId]. */ @@ -55,12 +56,6 @@ object NappletRequestRouter { val subId: String, ) : Outcome - /** Start streaming `identity.changed` pushes when the active user's key changes (fire-and-forget). */ - data object WatchIdentity : Outcome - - /** Stop the `identity.changed` stream (fire-and-forget; no reply). */ - data object UnwatchIdentity : Outcome - /** Push these envelope(s) to the applet immediately, unkeyed (e.g. an EOSE closing a refused sub). */ data class Push( val payloads: List, @@ -89,7 +84,7 @@ object NappletRequestRouter { declared: Set, payload: String, ): Outcome { - val requestType = runCatching { NappletProtocolJson.readType(payload) }.getOrNull() ?: "napplet" + val requestType = runCatching { NappletProtocolJson.readType(payload) }.getOrNull() ?: return Outcome.Ignore // Fire-and-forget edge ops that never reach the broker. when (requestType) { @@ -97,25 +92,9 @@ object NappletRequestRouter { val subId = runCatching { NappletProtocolJson.readSubId(payload) }.getOrNull() return if (subId != null) Outcome.CloseSubscription(subId) else Outcome.Ignore } - "resource.cancel" -> - return Outcome.Reply(NappletProtocolJson.encodeResponse(requestType, NappletResponse.Done)) - // identity.watch/unwatch are a push subscription (like relay.subscribe), gated on the - // IDENTITY declaration directly — the actual pubkey stream is the host's job. The - // ledger still gets a say: this bypasses NappletBroker.handle, so without an explicit - // check a standing IDENTITY denial would not stop the pushes (and would keep leaking - // account-switch timing and every npub the user rotates between). - "identity.watch" -> - return if (NappletCapability.IDENTITY in declared && - !broker.isDenied(identity, NappletCapability.IDENTITY) - ) { - Outcome.WatchIdentity - } else { - Outcome.Ignore - } - "identity.unwatch" -> - return Outcome.UnwatchIdentity + "resource.cancel" -> return Outcome.Ignore // inc bus: a topic pub/sub between napplets/services, authorized on the INC declaration - // alone (like identity.watch) — no per-call consent. The host owns the cross-session fan-out. + // alone. The host owns the cross-session fan-out. "inc.subscribe" -> { val topic = runCatching { NappletProtocolJson.readTopic(payload) }.getOrNull() return if (topic != null && NappletCapability.INC in declared) Outcome.SubscribeInc(topic) else Outcome.Ignore @@ -136,7 +115,12 @@ object NappletRequestRouter { val request = runCatching { NappletProtocolJson.decodeRequest(payload) }.getOrNull() - ?: return Outcome.Reply(NappletProtocolJson.encodeResponse(requestType, NappletResponse.Failed("Malformed or unsupported request."))) + ?: return if (runCatching { NappletProtocolJson.readId(payload) }.getOrNull() != null) { + val failure = NappletResponse.Failed("Malformed or unsupported request.") + Outcome.Reply(NappletProtocolJson.encodeResponse(requestType, failure), failure) + } else { + Outcome.Ignore + } val response = broker.handle(identity, request, declared) @@ -153,6 +137,6 @@ object NappletRequestRouter { } } - return Outcome.Reply(NappletProtocolJson.encodeResponse(requestType, response)) + return Outcome.Reply(NappletProtocolJson.encodeResponse(requestType, response), response) } } diff --git a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/napplet/protocol/NappletProtocolJson.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/napplet/protocol/NappletProtocolJson.kt index 4a1c52243b..7cfb2a95e0 100644 --- a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/napplet/protocol/NappletProtocolJson.kt +++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/napplet/protocol/NappletProtocolJson.kt @@ -26,6 +26,7 @@ import kotlinx.serialization.json.Json import kotlinx.serialization.json.JsonNull import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.add +import kotlinx.serialization.json.addJsonObject import kotlinx.serialization.json.buildJsonArray import kotlinx.serialization.json.buildJsonObject import kotlinx.serialization.json.int @@ -34,6 +35,7 @@ import kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.jsonPrimitive import kotlinx.serialization.json.long import kotlinx.serialization.json.put +import kotlinx.serialization.json.putJsonArray import kotlinx.serialization.json.putJsonObject import java.util.Base64 @@ -59,10 +61,13 @@ object NappletProtocolJson { /** The `type` discriminant of a request envelope, used to build the matching `.result` type. */ fun readType(envelopeJson: String): String? = json.parseToJsonElement(envelopeJson).jsonObject.str("type") + /** The request `id`, when the envelope expects a correlated reply. */ + fun readId(envelopeJson: String): String? = json.parseToJsonElement(envelopeJson).jsonObject.str("id") + /** The `subId` of a subscription request, used to key the `relay.event`/`relay.eose` pushes back to it. */ fun readSubId(envelopeJson: String): String? = json.parseToJsonElement(envelopeJson).jsonObject.str("subId") - /** A `relay.event` push: delivers one matching [event] to the subscription [subId] (no request id). */ + /** A `relay.event` push carrying the NAP-RELAY `RelayEventResult` wrapper. */ fun encodeRelayEvent( subId: String, event: Event, @@ -70,7 +75,9 @@ object NappletProtocolJson { buildJsonObject { put("type", "relay.event") put("subId", subId) - put("event", json.parseToJsonElement(event.toJson())) + putJsonObject("result") { + put("event", json.parseToJsonElement(event.toJson())) + } }.toString() /** A `relay.eose` push: signals end-of-stored-events for the subscription [subId]. */ @@ -130,29 +137,10 @@ object NappletProtocolJson { put("reason", reason) }.toString() - /** - * The `shell.init` handshake reply (`@napplet/core`): the capability environment the napplet - * caches and answers `shell.supports()` from. [domains] is the set of NAP domains this shell - * will broker for the applet; [services] mirrors them. We don't advertise numbered protocols. - */ - fun encodeShellInit( - domains: List, - services: List, - ): String = - buildJsonObject { - put("type", "shell.init") - putJsonObject("capabilities") { - put("domains", buildJsonArray { domains.forEach { add(it) } }) - putJsonObject("protocols") {} - } - put("services", buildJsonArray { services.forEach { add(it) } }) - }.toString() - /** Parses a request envelope. Returns `null` for an unrecognized `type` so the broker can deny it. */ fun decodeRequest(envelopeJson: String): NappletRequest? { val o = json.parseToJsonElement(envelopeJson).jsonObject return when (o.str("type")) { - "shell.supports" -> NappletRequest.ShellSupports(o.req("domain"), o.str("protocol")) "theme.get" -> NappletRequest.ThemeGet "identity.getPublicKey" -> NappletRequest.GetPublicKey "relay.publish" -> { @@ -181,10 +169,10 @@ object NappletProtocolJson { createdAt = t["created_at"]?.jsonPrimitive?.long ?: (System.currentTimeMillis() / 1000), ) } - "storage.get" -> NappletRequest.StorageGet(o.req("key")) - "storage.set" -> NappletRequest.StorageSet(o.req("key"), o.req("value")) - "storage.remove" -> NappletRequest.StorageRemove(o.req("key")) - "storage.keys" -> NappletRequest.StorageKeys + "storage.get" -> NappletRequest.StorageGet(o.req("key"), o.storageScope()) + "storage.set" -> NappletRequest.StorageSet(o.req("key"), o.req("value"), o.storageScope()) + "storage.remove" -> NappletRequest.StorageRemove(o.req("key"), o.storageScope()) + "storage.keys" -> NappletRequest.StorageKeys(o.storageScope()) "notify.create" -> NappletRequest.NotifyCreate(o.str("title") ?: "", o.str("body") ?: "") "notify.list" -> NappletRequest.NotifyList "notify.dismiss" -> NappletRequest.NotifyDismiss(o.str("notificationId") ?: o.str("id") ?: "") @@ -194,7 +182,9 @@ object NappletProtocolJson { } "keys.unregisterAction" -> NappletRequest.UnregisterAction(o.req("actionId")) "value.payInvoice" -> NappletRequest.PayInvoice(o.req("invoice")) + "resource.info" -> NappletRequest.ResourceInfo "resource.bytes" -> NappletRequest.ResourceBytes(o.req("url")) + "resource.bytesMany" -> NappletRequest.ResourceBytesMany(o.getValue("urls").jsonArray.map { it.jsonPrimitive.content }) "upload.upload" -> { // UploadUploadMessage: { type, id, request: { data, mimeType?, filename?, ... } }. // The Blob in `request.data` is inlined as base64 `request.dataBase64` by shell.html. @@ -209,7 +199,7 @@ object NappletProtocolJson { // Any other identity.* read (getProfile/getRelays/getFollows/getList/...) routes through // a generic IdentityRead; the broker/gateway decides which are implemented. val type = o.str("type") - if (type != null && type.startsWith("identity.")) { + if (type != null && type in IDENTITY_READ_TYPES) { NappletRequest.IdentityRead(type.removePrefix("identity."), o.str("listType") ?: o.str("argument")) } else { null @@ -230,6 +220,7 @@ object NappletProtocolJson { when (response) { is NappletResponse.NotifyCreated -> "notify.created" is NappletResponse.NotifyListed -> "notify.listed" + is NappletResponse.ResourceFailure -> "$requestType.error" else -> "$requestType.result" } put("type", responseType) @@ -247,11 +238,11 @@ object NappletProtocolJson { } is NappletResponse.Events -> { put("ok", true) - put("events", buildJsonArray { response.events.forEach { add(json.parseToJsonElement(it.toJson())) } }) - } - is NappletResponse.Supported -> { - put("ok", true) - put("supported", response.supported) + putJsonArray("events") { + response.events.forEach { event -> + addJsonObject { put("event", json.parseToJsonElement(event.toJson())) } + } + } } is NappletResponse.ActionRegistered -> { put("ok", true) @@ -279,6 +270,42 @@ object NappletProtocolJson { put("bytes", Base64.getEncoder().encodeToString(response.bytes)) put("mime", response.contentType) } + is NappletResponse.ResourceInfo -> { + put("ok", true) + putJsonObject("info") { + putJsonArray("schemes") { + response.schemes.forEach { scheme -> + addJsonObject { + put("scheme", scheme) + put("enabled", true) + } + } + } + put("maxBytes", response.maxBytes) + put("maxUrls", response.maxUrls) + } + } + is NappletResponse.ResourceItems -> { + put("ok", true) + putJsonArray("items") { + response.items.forEach { item -> + addJsonObject { + put("url", item.url) + put("ok", item.resource != null) + item.resource?.let { + put("bytes", Base64.getEncoder().encodeToString(it.bytes)) + put("mime", it.contentType) + } + item.error?.let { put("error", it) } + item.message?.let { put("message", it) } + } + } + } + } + is NappletResponse.ResourceFailure -> { + put("error", response.error) + response.message?.let { put("message", it) } + } is NappletResponse.Uploaded -> { // UploadResult: { ok, uploadId, status, url?, sha256?, size?, mimeType?, ... }. put("ok", true) @@ -397,6 +424,8 @@ object NappletProtocolJson { private fun JsonObject.kindOf(): Int = getValue("kind").jsonPrimitive.int + private fun JsonObject.storageScope(): NappletStorageScope = if (str("scope") == "instance") NappletStorageScope.INSTANCE else NappletStorageScope.SHARED + /** The result field a given identity read returns, matching `@napplet/nap` identity message types. */ private fun identityResultField(requestType: String): String = when (requestType) { @@ -408,4 +437,16 @@ object NappletProtocolJson { "identity.getBadges" -> "badges" else -> "result" } + + private val IDENTITY_READ_TYPES = + setOf( + "identity.getRelays", + "identity.getProfile", + "identity.getFollows", + "identity.getList", + "identity.getZaps", + "identity.getMutes", + "identity.getBlocked", + "identity.getBadges", + ) } diff --git a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletRequestRouterTest.kt b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletRequestRouterTest.kt index 94e3e4c763..6269b971ea 100644 --- a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletRequestRouterTest.kt +++ b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletRequestRouterTest.kt @@ -85,11 +85,12 @@ class NappletRequestRouterTest { } @Test - fun resourceCancelRepliesDone() = + fun resourceCancelIsSilentlyHandled() = runTest { - val outcome = route("""{"type":"resource.cancel"}""") - assertIs(outcome) - assertTrue(outcome.payload.contains("resource.cancel.result")) + assertEquals( + NappletRequestRouter.Outcome.Ignore, + route("""{"type":"resource.cancel"}"""), + ) } @Test @@ -117,11 +118,25 @@ class NappletRequestRouterTest { } @Test - fun malformedRequestRepliesFailed() = + fun unknownAndMalformedRequestsAreSilentlyIgnored() = runTest { - val outcome = route("""{"type":"totally.unknown"}""") - assertIs(outcome) - assertTrue(outcome.payload.contains("failed")) + assertEquals(NappletRequestRouter.Outcome.Ignore, route("""{"type":"totally.unknown"}""")) + assertEquals(NappletRequestRouter.Outcome.Ignore, route("not json")) + assertEquals(NappletRequestRouter.Outcome.Ignore, route("""{"type":"relay.publish"}""")) + } + + @Test + fun keyedUnknownAndMalformedRequestsReplyWithFailure() = + runTest { + val unknown = route("""{"type":"totally.unknown","id":"r1"}""") + assertIs(unknown) + assertTrue(unknown.payload.contains("totally.unknown.result")) + assertTrue(unknown.payload.contains("Malformed or unsupported request.")) + + val malformed = route("""{"type":"relay.publish","id":"r2"}""") + assertIs(malformed) + assertTrue(malformed.payload.contains("relay.publish.result")) + assertTrue(malformed.payload.contains("Malformed or unsupported request.")) } @Test @@ -153,29 +168,9 @@ class NappletRequestRouterTest { } @Test - fun identityWatchWhenDeclaredBecomesWatchIdentity() = + fun removedIdentityWatchMessagesAreIgnored() = runTest { - assertEquals( - NappletRequestRouter.Outcome.WatchIdentity, - NappletRequestRouter.route(broker(), applet, allDeclared, """{"type":"identity.watch"}"""), - ) - } - - @Test - fun identityWatchWithoutDeclarationIsIgnored() = - runTest { - assertEquals( - NappletRequestRouter.Outcome.Ignore, - NappletRequestRouter.route(broker(), applet, emptySet(), """{"type":"identity.watch"}"""), - ) - } - - @Test - fun identityUnwatchBecomesUnwatchIdentity() = - runTest { - assertEquals( - NappletRequestRouter.Outcome.UnwatchIdentity, - NappletRequestRouter.route(broker(), applet, emptySet(), """{"type":"identity.unwatch"}"""), - ) + assertEquals(NappletRequestRouter.Outcome.Ignore, route("""{"type":"identity.watch"}""")) + assertEquals(NappletRequestRouter.Outcome.Ignore, route("""{"type":"identity.unwatch"}""")) } } diff --git a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/relayClient/event/EventFinderFilterAssemblyTest.kt b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/relayClient/event/EventFinderFilterAssemblyTest.kt new file mode 100644 index 0000000000..cfb3c42909 --- /dev/null +++ b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/relayClient/event/EventFinderFilterAssemblyTest.kt @@ -0,0 +1,185 @@ +/* + * 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.relayClient.event + +import com.vitorpamplona.amethyst.commons.model.AddressableNote +import com.vitorpamplona.amethyst.commons.model.Channel +import com.vitorpamplona.amethyst.commons.model.Note +import com.vitorpamplona.amethyst.commons.model.User +import com.vitorpamplona.amethyst.commons.model.cache.ICacheEventStream +import com.vitorpamplona.amethyst.commons.model.cache.ICacheProvider +import com.vitorpamplona.amethyst.commons.relayClient.event.loaders.filterMissingEvents +import com.vitorpamplona.amethyst.commons.relayClient.user.UserFinderAccount +import com.vitorpamplona.quartz.nip01Core.core.Address +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.hints.HintIndexer +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip85TrustedAssertions.list.tags.ServiceProviderTag +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * Unit tests for the extracted (Phase 2b) per-note event-finder filter assembly + * and the [ICacheProvider] seam it relies on. + */ +class EventFinderFilterAssemblyTest { + private val relay1 = NormalizedRelayUrl("wss://relay1.test/") + private val relay2 = NormalizedRelayUrl("wss://relay2.test/") + + /** + * One batched `ids` filter per relay — NOT one filter per event id — and the + * ids are sorted deterministically so REQs dedup across rebuilds. + */ + @Test + fun `filterMissingEvents batches one filter per relay with sorted ids`() { + val filters = + filterMissingEvents( + mapOf( + relay1 to setOf("bbbb", "aaaa", "cccc"), + relay2 to setOf("dddd"), + ), + ) + + assertEquals("one batched filter per relay", 2, filters.size) + + val r1 = filters.first { it.relay == relay1 } + assertEquals(listOf("aaaa", "bbbb", "cccc"), r1.filter.ids) + + val r2 = filters.first { it.relay == relay2 } + assertEquals(listOf("dddd"), r2.filter.ids) + } + + @Test + fun `filterMissingEvents skips relays with no ids and empty input`() { + assertTrue(filterMissingEvents(emptyMap()).isEmpty()) + assertTrue(filterMissingEvents(mapOf(relay1 to emptySet())).isEmpty()) + } + + /** + * The per-note event finder fans a missing event out to the account's own + * search relays ([UserFinderAccount.searchOnlyRelays]) plus the + * follow/mine/search default — NOT the trusted-relay union that + * [UserFinderAccount.searchRelays] adds. Regression guard for reusing the + * wrong getter here (which would silently widen every missing-event REQ to + * trusted relays). + */ + @Test + fun `filterMissingEvents(keys) uses searchOnlyRelays, not the trusted searchRelays union`() { + val searchOnly = NormalizedRelayUrl("wss://search.test/") + val trustedExtra = NormalizedRelayUrl("wss://trusted.test/") // only in searchRelays() + val default = NormalizedRelayUrl("wss://default.test/") // followPlusAllMineWithSearchRelays() + + val account = + object : StubAccount() { + override fun searchOnlyRelays() = setOf(searchOnly) + + override fun searchRelays() = setOf(searchOnly, trustedExtra) + + override fun followPlusAllMineWithSearchRelays() = setOf(default) + } + + // event == null and not addressable → a "missing event"; no author/replies, + // and the stub cache has empty relay hints, so potentialRelaysToFindEvent is + // empty and the code falls back to the default relays + searchOnlyRelays. + val note = Note("a".repeat(64)) + + val filters = filterMissingEvents(StubCache(), listOf(EventFinderQueryState(note, account))) + val relays = filters.map { it.relay }.toSet() + + assertTrue("search-only relay carries the missing-event REQ", searchOnly in relays) + assertTrue("follow/mine/search default carries it", default in relays) + assertFalse("trusted relay (only in searchRelays) must NOT be fanned out to", trustedExtra in relays) + filters.forEach { assertEquals(listOf(note.idHex), it.filter.ids) } + } + + /** + * The new default [ICacheProvider.checkGetOrCreateUser] must swallow a + * malformed-key throw and return null (the event-finder follows pubkey hints + * parsed out of arbitrary events, some of which are junk). + */ + @Test + fun `checkGetOrCreateUser tolerates a throwing getOrCreateUser`() { + val throwing = + object : StubCache() { + override fun getOrCreateUser(pubkey: HexKey): User? = throw IllegalArgumentException("bad key") + } + assertNull(throwing.checkGetOrCreateUser("not-a-key")) + } + + @Test + fun `checkGetOrCreateUser passes through a null result`() { + assertNull(StubCache().checkGetOrCreateUser("00")) + } + + /** Minimal [ICacheProvider] that returns nothing; override per test. */ + private open class StubCache : ICacheProvider { + override val relayHints = HintIndexer() + + override fun getAnyChannel(note: Note): Channel? = null + + override fun getUserIfExists(pubkey: HexKey): User? = null + + override fun countUsers(predicate: (String, User) -> Boolean): Int = 0 + + override fun getNoteIfExists(hexKey: HexKey): Note? = null + + override fun checkGetOrCreateNote(hexKey: HexKey): Note? = null + + override fun getOrCreateAddressableNote(key: Address): AddressableNote = error("unused") + + override fun getEventStream(): ICacheEventStream = error("unused") + + override fun hasBeenDeleted(event: Any): Boolean = false + + override fun getOrCreateUser(pubkey: HexKey): User? = null + + override fun justConsumeMyOwnEvent(event: Event): Boolean = false + } + + /** Minimal [UserFinderAccount] returning nothing; override the relevant getters per test. */ + private open class StubAccount : UserFinderAccount { + override val userFinderPubkeyHex: HexKey = "00".repeat(32) + + override fun indexRelays(): Set = emptySet() + + override fun outboxHomeRelays(): Set = emptySet() + + override fun searchRelays(): Set = emptySet() + + override fun searchOnlyRelays(): Set = emptySet() + + override fun followPlusAllMineWithSearchRelays(): Set = emptySet() + + override fun commonRelays(): Set = emptySet() + + override fun cardHomeRelays(): Set = emptySet() + + override fun trustProvider(): ServiceProviderTag? = null + + override fun followerCountProvider(): ServiceProviderTag? = null + + override fun declaredFollowsByOutboxRelay(): Map> = emptyMap() + } +} diff --git a/desktopApp/plans/2026-08-06-shared-metadata-loading-upstream-reconciliation-plan.md b/desktopApp/plans/2026-08-06-shared-metadata-loading-upstream-reconciliation-plan.md new file mode 100644 index 0000000000..e30caa24c7 --- /dev/null +++ b/desktopApp/plans/2026-08-06-shared-metadata-loading-upstream-reconciliation-plan.md @@ -0,0 +1,110 @@ +# Shared Metadata Loading — Upstream Reconciliation Plan +*Redo the commons extraction of the per-user metadata finder and per-note event finder on top of current `upstream/main`, abandoning the stale rebase of `worktree-plan-shared-metadata-loading` (`682c6000f5`).* + +## 0. Executive summary of what changed under us + +Upstream re-architected the same subsystem but **also completed the model-sharing prerequisite we depended on**: + +- `amethyst.model.User`, `UserContext`, `Note` are now **typealiases** to `commons.model.*` (`amethyst/model/User.kt`). `SincePerRelayMap`, `MutableTime`, `EOSERelayList` are typealiases to `commons.relays.*` (`amethyst/service/relays/EOSE.kt`). Finder bodies are already type-compatible with commonMain. +- `LocalCache` is an `object` implementing `commons.model.cache.ICacheProvider` (exposes `getUserIfExists`, `getNoteIfExists`, `getOrCreateUser`, `relayHints`, …). The finders call exactly these. +- **Two new cross-cutting mechanisms**: + 1. `AccountScopedQuery { val account: amethyst.model.Account }` — implemented by `UserFinderQueryState`/`EventFinderQueryState` so base managers can attribute subscriptions to an account. + 2. `ExplainedFilter`/`SubPurpose` (already in commons) — every emitted filter is tagged with its purpose. + +**Most important finding:** the base **commons** managers never read `.account`. Attribution lives in *amethyst-side* managers (`PerUserEoseManager`, `PerUniqueIdEoseManager`, …) and each reads exactly `(key as? AccountScopedQuery)?.account?.userProfile()?.pubkeyHex` — **only a pubkey hex**. AND the four finder sub-assemblers extend the **commons** base managers, not the amethyst attribution managers — they attribute *inline* by computing `soleAccountPubKey` and passing `accountPubKeys` into their `ExplainedFilter`s. So the finders do not depend on the amethyst attribution managers; they need only a pubkey. + +## A. Map of upstream's current subsystem + +### User side — `amethyst/.../reqCommand/user/` +- `UserFinderFilterAssembler.kt` — `UserFinderQueryState(user, override val account: Account) : AccountScopedQuery`; groups `UserOutboxFinderSubAssembler`, `UserWatcherSubAssembler`, `UserReportsSubAssembler`, `UserCardsSubAssembler`. +- `UserFinderFilterAssemblerSubscription.kt` — composable entry (`(user, accountViewModel)`) + `UserFinderByParentFilterAssemblerSubscription`; uses `AccountViewModel.account`, `dataSources().userFinder`, commons `LifecycleAwareKeyDataSourceSubscription`. +- `UserObservers.kt` — `observeUser*`. +- `loaders/UserOutboxFinderSubAssembler.kt` — extends commons `BaseEoseManager`; reads `it.account` → `pickRelaysToLoadUsers(...)`; emits `ExplainedFilter(purpose = RELAY_LISTS, accountPubKeys = listOfNotNull(soleAccountPubKey))`. +- `watchers/UserWatcherSubAssembler.kt` — commons `BaseEoseManager`; reads `account.indexerRelayList.flow.value`; calls `filterUserMetadataForKey(...)`. +- `watchers/FilterUserMetadataForKey.kt` — emits `ExplainedFilter(PROFILE_METADATA, …)`; reads `LocalCache.relayHints.hintsForKey(...)` **statically** (the one non-injected cache ref). +- `watchers/UserReportsSubAssembler.kt` — commons `SingleSubEoseManager`; reads `account.declaredFollowsPerOutboxRelay.value`, `account.userProfile().pubkeyHex`. +- `watchers/UserCardsSubAssembler.kt` — commons `SingleSubEoseManager`; reads `account.homeRelays.flow.value`, `account.trustProviderList.liveUserRankProvider`, `…liveUserFollowerCount`, `account.userProfile().pubkeyHex`; emits `filterContactCardsToTargetKeysFromTrustedAccountsInTheRelay(...)` (already in commons `assemblers/ContactCardFilters.kt`). + +### Event side — `amethyst/.../reqCommand/event/` +- `EventFinderFilterAssembler.kt` — `EventFinderQueryState(note, override val account: Account) : AccountScopedQuery`; groups `NoteEventLoaderSubAssembler`, `EventWatcherSubAssembler`, `AddressableAuthorRelayLoaderSubAssembler(cache, ::allKeys, userFinder)`. +- `EventFinderFilterAssemblerSubscription.kt` — reads `accountViewModel.account`, `dataSources().eventFinder`. +- `EventObservers.kt` — `observeNote*` (one UI-only spot reads `accountViewModel.account.userProfile().pubkeyHex` for a moderator check). +- `loaders/NoteEventLoaderSubAssembler.kt`; `loaders/FilterMissingEvents.kt` (reads `key.account.followPlusAllMineWithSearch.flow.value`, `key.account.searchRelayList.flow.value`; `SubPurpose.REFERENCED_EVENTS`); `loaders/FilterMissingAddressables.kt` (`REFERENCED_EVENTS`). +- `loaders/AddressableAuthorRelayLoaderSubAssembler.kt` — constructs `UserFinderQueryState(author, key.account)`. +- `watchers/EventWatcherSubAssembler.kt` — commons `SingleSubEoseManager`; reads `it.account.userProfile().pubkeyHex` (attribution only); calls `filterRepliesAndReactionsToNotes/Addresses(...)`. +- `watchers/FilterRepliesAndReactionsToNotes.kt`, `FilterRepliesAndReactionsToAddresses.kt` — emit `ExplainedFilter(ENGAGEMENT, …)`. + +### Base managers and attribution +- commons `BaseEoseManager`, `PerKeyEoseManager`, `SingleSubEoseManager` — **account-agnostic**. +- amethyst `PerUserEoseManager`, `PerUniqueIdEoseManager`, `PerUserAndFollowListEoseManager`, `SingleSubNoEoseCacheEoseManager` — do `f.attributedTo(pk)` where `pk = (key as? AccountScopedQuery).account.userProfile().pubkeyHex`. The finder sub-assemblers do NOT use these; they attribute inline. +- `ExplainedFilter.attributedTo(accountPubKey: HexKey)`; `SubPurpose`/`SubPurposeGroup` — commonMain. + +## B. Account-seam decision — CHOSEN + +**Keep `UserFinderAccount` as the narrow commons seam, carrying the attribution pubkey via `userFinderPubkeyHex`. Do NOT move `AccountScopedQuery` to commons. Drop `AccountScopedQuery` from the two finder query states.** + +Justification (grounded in code): +1. Attribution reduces to `.userProfile().pubkeyHex` — a `HexKey`, already on `UserFinderAccount.userFinderPubkeyHex`. +2. The loaders' account reads are all snapshot relay-hint / follow-graph getters — exactly the `UserFinderAccount` surface (+2 additions). +3. `AccountScopedQuery` is declared over `amethyst.model.Account` and implemented by ~66 amethyst query states — can't move without dragging `Account`. +4. The finder query states never flow through the amethyst attribution managers, so they don't need `AccountScopedQuery` at all → dropping it removes the collision. + +Seam shape (commons `UserFinderAccount`), = our old branch + 2 additions: +```kotlin +interface UserFinderAccount { + val userFinderPubkeyHex: HexKey // attribution pubkey → ExplainedFilter.accountPubKeys + fun indexRelays(): Set + fun outboxHomeRelays(): Set + fun searchRelays(): Set + fun followPlusAllMineWithSearchRelays(): Set + fun commonRelays(): Set + fun cardHomeRelays(): Set + fun trustProvider(): ServiceProviderTag? + fun followerCountProvider(): ServiceProviderTag? // NEW (UserCards reads liveUserFollowerCount) + fun declaredFollowsByOutboxRelay(): Map> +} +``` +`EventFinderQueryState` reuses `UserFinderAccount` (needs only follow+search relays + pubkey). Attribution: `soleAccountPubKey = keys.map { it.account.userFinderPubkeyHex }.singleOrNull()` → `accountPubKeys = listOfNotNull(soleAccountPubKey)`. + +## C. SubPurpose mapping (preserve upstream tags across the move) + +| Moved helper | SubPurpose | +|---|---| +| `FilterUserMetadataForKey` (kind 0 bundle) | `PROFILE_METADATA` (runsInBackground) | +| `UserOutboxFinderSubAssembler` | `RELAY_LISTS` | +| `UserCardsSubAssembler` → `ContactCardFilters` (already commons) | unchanged | +| `UserReportsSubAssembler` → `filterReportsToKeysFromTrusted` | `MODERATION` | +| `EventWatcherSubAssembler` → replies/reactions | `ENGAGEMENT` | +| `NoteEventLoader` → `FilterMissingEvents/Addressables` | `REFERENCED_EVENTS` | + +`ExplainedFilter`/`SubPurpose`/`attributedTo`/`ContactCardFilters` are already commonMain + iOS-pure. Only change: `accountPubKeys` sourced from `userFinderPubkeyHex`. + +## D. Portable-unchanged vs must-rework + +**Unchanged (body identical after package move + seam swap):** the filter builders (`FilterUserMetadataForKey`, `FilterReportsToKey`, `FilterMissingEvents/Addressables`, `FilterRepliesAndReactionsToNotes/Addresses`), `observeUser*`/`observeNote*`, `UserFinderAccount` + CompositionLocals, `pickRelaysToLoadUsers` inner overload. + +**Must rework:** +1. **Move `EOSEAccountFast`** (`amethyst/service/relays/EOSE.kt`) to commons `relays/` + amethyst typealias — prereq for the loaders. (Purity risk: verify no `System.currentTimeMillis`/`Thread.sleep`.) +2. **`LocalCache.relayHints` static ref** in `FilterUserMetadataForKey` → injected `cache.relayHints` (add `val relayHints: HintIndexer` to `ICacheProvider`; LocalCache already has it). +3. **Account-seam swap** across the four user sub-assemblers + event loaders/watchers (getter-for-flow mapping listed in §B). +4. **`pickRelaysToLoadUsers`** — feed commons inner overload from `UserFinderAccount` getters (amethyst keeps a thin `Account`→relay-set wrapper). +5. **Drop `AccountScopedQuery`** from the two finder query states (verified no amethyst attribution manager consumes them). +6. `AddressableAuthorRelayLoaderSubAssembler` — `UserFinderQueryState(author, key.account)` now takes `UserFinderAccount`. Clean. + +**Desktop wiring (Phase 3/3b old plan) — unaffected:** `observeUser*`, `EventFinderFilterAssemblerSubscription(note)`, Locals, DM/search/notifications adoption, fast index-relay warm-up. `DesktopIAccount` adds `followerCountProvider() = null` (already degrades trust/declaredFollows). + +## E. Phase / commit sequence (fresh branch `feat/shared-metadata-loading-v2` off `upstream/main`) + +Cherry-pick *content*, not commits. Gates after each commons/amethyst commit: `:commons:verifyKmpPurity`, `:amethyst:compilePlayDebugKotlin`, `:commons:compileKotlinJvm` + `:desktopApp:compileKotlinJvm`. + +- **Phase 0 — prereqs:** (0a) move `EOSEAccountFast` → commons + typealias; (0b) add `relayHints` to `ICacheProvider`. +- **Phase 1 — seam:** (1a) add commons `UserFinderAccount` (+`followerCountProvider()`); (1b) `Account implements UserFinderAccount`. +- **Phase 2 — user finder → commons:** (2a) filter builders + `pickRelaysToLoadUsers` inner; (2b) the 4 sub-assemblers; (2c) `UserFinderFilterAssembler`+`UserFinderQueryState` (drop `AccountScopedQuery`) + amethyst typealias shim + commons `UserFinderSubscription`/Locals; keep composable subscription in amethyst delegating. +- **Phase 3 — event finder → commons:** (3a) filter builders; (3b) loaders/watchers + addressable bridge; (3c) assembler+state (drop `AccountScopedQuery`) + shim + `LocalEventFinder` + `observeNote*` to commons. +- **Phase 4 — Desktop wiring:** (4a) `DesktopIAccount implements UserFinderAccount`; (4b) provide Locals in `Main.kt`, wire DM/search/notifications + warm-up onto `observeUser*`/`EventFinderFilterAssemblerSubscription`. +- **Phase 5 — cleanup & tests:** delete dead originals; run `ExplainedFilterTest` + finder tests + full `:commons:check`. + +### Risk callouts +- `EOSEAccountFast` purity is the likeliest `verifyKmpPurity` tripwire. +- Preserve inline `soleAccountPubKey` attribution so "Active Relay Subscriptions" still files single-account REQs correctly (and shows "not attributed" when accounts pool relays — don't regress to per-account splitting). +- Do NOT re-introduce `AccountScopedQuery` on the two finder states; if a future upstream manager consumes them, add a thin amethyst adapter instead of widening the commons seam. diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt index 3c5faabd88..704c5f1a2c 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt @@ -81,8 +81,11 @@ import com.vitorpamplona.amethyst.commons.moderation.PreferencesHashtagSpamSetti import com.vitorpamplona.amethyst.commons.moderation.notifications.PreferencesNotificationReadState import com.vitorpamplona.amethyst.commons.moderation.notifications.PreferencesNotificationSettings import com.vitorpamplona.amethyst.commons.relayClient.auth.AuthApprovalBanner +import com.vitorpamplona.amethyst.commons.relayClient.event.LocalEventFinder import com.vitorpamplona.amethyst.commons.relayClient.nip17Dm.DmInboxRelayResolver import com.vitorpamplona.amethyst.commons.relayClient.nip17Dm.unwrapAndUnsealOrNull +import com.vitorpamplona.amethyst.commons.relayClient.user.LocalUserFinder +import com.vitorpamplona.amethyst.commons.relayClient.user.LocalUserFinderAccount import com.vitorpamplona.amethyst.commons.scheduledposts.ScheduledPostStatus import com.vitorpamplona.amethyst.commons.wot.LocalWoTReady import com.vitorpamplona.amethyst.commons.wot.LocalWoTService @@ -1470,6 +1473,9 @@ private fun AppInner( LocalNamecoinService provides namecoinService, LocalSpamExemptKeys provides spamExemptKeys, com.vitorpamplona.amethyst.desktop.model.LocalDesktopIAccount provides iAccount, + LocalUserFinder provides subscriptionsCoordinator.userFinder, + LocalUserFinderAccount provides iAccount, + LocalEventFinder provides subscriptionsCoordinator.eventFinder, ) { val pendingAuthApprovals by authCoordinator.pendingApprovals.collectAsState() Column(modifier = Modifier.fillMaxSize()) { @@ -2114,6 +2120,9 @@ fun MainContent( LocalRelayCategories provides relayCategories, LocalBlossomServers provides iAccount.blossomServerList.flow, com.vitorpamplona.amethyst.desktop.model.LocalDesktopIAccount provides iAccount, + LocalUserFinder provides subscriptionsCoordinator.userFinder, + LocalUserFinderAccount provides iAccount, + LocalEventFinder provides subscriptionsCoordinator.eventFinder, com.vitorpamplona.amethyst.desktop.ui.LocalSnackbarHost provides snackbarHostState, com.vitorpamplona.amethyst.desktop.ui.relay.LocalAccountRelays provides accountRelays, com.vitorpamplona.amethyst.desktop.ui.deck.LocalDesktopCache provides localCache, diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/cache/DesktopLocalCache.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/cache/DesktopLocalCache.kt index b83d490a28..6c5979ac08 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/cache/DesktopLocalCache.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/cache/DesktopLocalCache.kt @@ -35,6 +35,7 @@ import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.crypto.checkSignature import com.vitorpamplona.quartz.nip01Core.crypto.verify +import com.vitorpamplona.quartz.nip01Core.hints.HintIndexer import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.tags.aTag.taggedAddresses @@ -90,6 +91,9 @@ class DesktopLocalCache : ICacheProvider { val addressableNotes = LargeSoftCache() private val deletedEvents = ConcurrentHashMap.newKeySet() + /** NIP-hints index accumulated from consumed events (event/address/pubkey → relay). */ + override val relayHints = HintIndexer() + val eventStream = DesktopCacheEventStream() /** Local relay store for persisting events to SQLite. Set from Main.kt on account login. */ diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/model/DesktopIAccount.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/model/DesktopIAccount.kt index 3d9b28bbb2..e68f95c233 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/model/DesktopIAccount.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/model/DesktopIAccount.kt @@ -35,6 +35,7 @@ import com.vitorpamplona.amethyst.commons.model.nipB7Blossom.BlossomServerListSt import com.vitorpamplona.amethyst.commons.model.privateChats.ChatroomList import com.vitorpamplona.amethyst.commons.moderation.PreferencesSensitiveContentSettings import com.vitorpamplona.amethyst.commons.relayClient.nip17Dm.DmInboxRelayResolver +import com.vitorpamplona.amethyst.commons.relayClient.user.UserFinderAccount import com.vitorpamplona.amethyst.desktop.account.AccountState import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache import com.vitorpamplona.amethyst.desktop.network.RelayConnectionManager @@ -64,6 +65,7 @@ import com.vitorpamplona.quartz.nip57Zaps.IPrivateZapsDecryptionCache import com.vitorpamplona.quartz.nip57Zaps.PrivateZapCache import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent +import com.vitorpamplona.quartz.nip85TrustedAssertions.list.tags.ServiceProviderTag import com.vitorpamplona.quartz.nip89AppHandlers.clientTag.NostrSignerWithClientTag import com.vitorpamplona.quartz.utils.DualCase import kotlinx.coroutines.CoroutineScope @@ -92,11 +94,45 @@ class DesktopIAccount( private val scope: CoroutineScope, private val accountRelays: DesktopAccountRelays? = null, val dmInboxResolver: DmInboxRelayResolver? = null, -) : IAccount { +) : IAccount, + UserFinderAccount { override val signer: NostrSigner = NostrSignerWithClientTag(accountState.signer, CLIENT_TAG_NAME) override val pubKey: String = accountState.pubKeyHex + // UserFinderAccount — Desktop's relay-hint view for the shared per-user + // metadata subscription layer. Desktop has no separate indexer/search relay + // lists nor a NIP-85 trust provider, so it routes discovery through its + // connected relays + NIP-65 outbox and degrades trust/reports to null/empty + // (contact-card ranking + report loading are best-effort here — see + // UserFinderAccount). + override val userFinderPubkeyHex: HexKey get() = pubKey + + override fun indexRelays(): Set = relayManager.connectedRelays.value + + override fun outboxHomeRelays(): Set = nip65RelayList.allFlowNoDefaults.value + relayManager.connectedRelays.value + + override fun searchRelays(): Set = relayManager.connectedRelays.value + + // Desktop has no separate NIP-51 search relay list; degrade to connected relays. + override fun searchOnlyRelays(): Set = relayManager.connectedRelays.value + + // Desktop has no merged follow/mine/search relay-list subsystem; route + // missing-event discovery through the connected relays (same degrade path + // as the other hints above). + override fun followPlusAllMineWithSearchRelays(): Set = relayManager.connectedRelays.value + + override fun commonRelays(): Set = relayManager.connectedRelays.value + + override fun cardHomeRelays(): Set = nip65RelayList.allFlowNoDefaults.value + + override fun trustProvider(): ServiceProviderTag? = null + + // Desktop has no NIP-85 rank/follower providers wired (same as trustProvider). + override fun followerCountProvider(): ServiceProviderTag? = null + + override fun declaredFollowsByOutboxRelay(): Map> = emptyMap() + // ----- State Classes (pin important notes via strong refs for GC retention) ----- val oldBookmarkState = OldBookmarkListState(signer, localCache, scope) diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/DesktopRelaySubscriptionsCoordinator.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/DesktopRelaySubscriptionsCoordinator.kt index 3a315d6507..78c1cffa71 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/DesktopRelaySubscriptionsCoordinator.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/DesktopRelaySubscriptionsCoordinator.kt @@ -22,8 +22,10 @@ package com.vitorpamplona.amethyst.desktop.subscriptions import com.vitorpamplona.amethyst.commons.model.Note import com.vitorpamplona.amethyst.commons.relayClient.assemblers.FeedMetadataCoordinator +import com.vitorpamplona.amethyst.commons.relayClient.event.EventFinderFilterAssembler import com.vitorpamplona.amethyst.commons.relayClient.preload.MetadataPreloader import com.vitorpamplona.amethyst.commons.relayClient.preload.MetadataRateLimiter +import com.vitorpamplona.amethyst.commons.relayClient.user.UserFinderFilterAssembler import com.vitorpamplona.amethyst.commons.service.BasicBundledInsert import com.vitorpamplona.amethyst.commons.wot.OutboxCacheGateway import com.vitorpamplona.amethyst.commons.wot.OutboxDispatcher @@ -32,6 +34,7 @@ import com.vitorpamplona.amethyst.desktop.model.DesktopDmRelayState import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.RelayOfflineTracker import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.fetchAll import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter @@ -83,6 +86,32 @@ class DesktopRelaySubscriptionsCoordinator( private val indexRelays: Set, private val localCache: DesktopLocalCache, ) { + /** + * Tracks relays that refuse to connect, so the shared user-finder skips + * them when picking discovery relays. Self-registers as a client listener. + */ + private val failureTracker = RelayOfflineTracker(client) + + /** + * The shared, composition-scoped per-user metadata subscription assembler + * (moved to commons). Desktop composables reach it via [LocalUserFinder] + * (provided in Main.kt) and subscribe per visible user through + * `observeUserPicture(user)` / `observeUserInfo(user)`, so metadata loads + * only for on-screen users. Coalesces every subscribed user into batched + * REQs — no per-avatar REQ storm. + */ + val userFinder = UserFinderFilterAssembler(client, localCache, failureTracker) + + /** + * The shared, composition-scoped per-note event subscription assembler + * (reactions / zaps / reposts / replies, moved to commons). Desktop note + * rows reach it via [LocalEventFinder] (provided in Main.kt) and subscribe + * per visible note through `EventFinderFilterAssemblerSubscription(note)`, so + * interactions load only for on-screen notes. Composes [userFinder] to + * resolve authors of not-yet-cached addressable notes. + */ + val eventFinder = EventFinderFilterAssembler(client, localCache, userFinder) + // Rate limiter: 20 requests per second to avoid flooding relays private val rateLimiter = MetadataRateLimiter(maxRequestsPerSecond = 20, scope = scope) diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt index 16696692b5..3c77deba9c 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt @@ -95,6 +95,10 @@ import com.vitorpamplona.amethyst.commons.model.nip02FollowList.FollowAction import com.vitorpamplona.amethyst.commons.model.nip05DnsIdentifiers.namecoin.NamecoinResolveState import com.vitorpamplona.amethyst.commons.model.nip25Reactions.ReactionAction import com.vitorpamplona.amethyst.commons.nip64Chess.RelaySyncStatus +import com.vitorpamplona.amethyst.commons.relayClient.event.EventFinderFilterAssemblerSubscription +import com.vitorpamplona.amethyst.commons.relayClient.user.UserFinderFilterAssemblerSubscription +import com.vitorpamplona.amethyst.commons.relayClient.user.observeUserName +import com.vitorpamplona.amethyst.commons.relayClient.user.observeUserPicture import com.vitorpamplona.amethyst.commons.richtext.UrlParser import com.vitorpamplona.amethyst.commons.search.AdvancedSearchBarState import com.vitorpamplona.amethyst.commons.search.QuerySerializer @@ -268,6 +272,20 @@ private fun FeedNoteCardBody( myPubKeyHex: String? = null, onFollow: ((String) -> Unit)? = null, ) { + // Load this note author's metadata (kind 0 + relay lists) only while this + // card is composed — i.e. on or near screen. The shared commons finder + // coalesces every visible author into batched REQs, giving per-row, + // visibility-scoped metadata loading that matches Android's model. + val cardAuthor = note.author + if (cardAuthor != null) { + UserFinderFilterAssemblerSubscription(cardAuthor) + } + + // Load this note's interactions (reactions / zaps / reposts / replies) only + // while the card is composed — the per-note counterpart to the author + // subscription above, coalesced into batched REQs by the shared event finder. + EventFinderFilterAssemblerSubscription(note) + if (event is PollEvent) { DesktopPollCard( note = note, @@ -751,17 +769,19 @@ fun FeedScreen( } } - // Viewport-aware metadata loading: only fetch for visible notes + buffer - // Uses snapshotFlow to avoid per-frame recomposition from scroll observation + // Fast first-paint warm-up: one batched kind-0 REQ straight to the index + // relays for the first visible authors. The per-row UserFinder subscriptions + // (in FeedNoteCardBody) are the source of truth — they do NIP-65 outbox + // routing and tear down off-screen — but their two-hop discovery is slower to + // first paint, so this immediate batch fills names/avatars instantly. Also + // prefetches reactions + referenced (repost/quote) notes for the initial set. LaunchedEffect(feedState, subscriptionsCoordinator) { if (subscriptionsCoordinator == null || feedState !is FeedState.Loaded) return@LaunchedEffect - val loadedFeed = feedState as FeedState.Loaded - // Initial load: batch metadata for first visible notes immediately val initialNotes = viewModel.feedState.visibleNotes().take(30) if (initialNotes.isNotEmpty()) { val authors = initialNotes.mapNotNull { it.author?.pubkeyHex }.distinct() - subscriptionsCoordinator.loadMetadataBatched(authors) + if (authors.isNotEmpty()) subscriptionsCoordinator.loadMetadataBatched(authors) subscriptionsCoordinator.loadMetadataForNotes(initialNotes) } } @@ -988,7 +1008,11 @@ fun FeedScreen( val loadedState by state.feed.collectAsState() val lazyListState = homeFeedLazyListState - // Viewport-aware scroll observation: fetch metadata for newly visible notes + // Fast-path warm-up on scroll: batch a kind-0 REQ to index + // relays for authors entering the viewport (+10 buffer), so + // names/avatars paint immediately. Complementary to the per-row + // FeedNoteCardBody subscriptions, which remain the source of + // truth (outbox routing + off-screen teardown). LaunchedEffect(lazyListState, loadedState) { if (subscriptionsCoordinator == null) return@LaunchedEffect val feedList = loadedState.list @@ -999,7 +1023,7 @@ fun FeedScreen( if (info.visibleItemsInfo.isEmpty()) return@snapshotFlow -1 to -1 info.visibleItemsInfo.first().index to info.visibleItemsInfo.last().index }.distinctUntilChanged() - .debounce(500) + .debounce(300) .collect { (first, last) -> if (first < 0) return@collect val from = (first - 10).coerceAtLeast(0) @@ -1974,15 +1998,9 @@ private fun ExpandedNoteContent( // Get reply notes from cache — recompute when replies change val replyNotes = remember(repliesState) { note.replies.sortedByDescending { it.createdAt() } } - // Load metadata for reply authors - LaunchedEffect(replyNotes, subscriptionsCoordinator) { - if (subscriptionsCoordinator != null && replyNotes.isNotEmpty()) { - val authors = replyNotes.mapNotNull { it.event?.pubKey }.distinct() - if (authors.isNotEmpty()) { - subscriptionsCoordinator.loadMetadataBatched(authors) - } - } - } + // Reply-author metadata (kind 0) is loaded per-row: each CommentItem below + // opens its own composition-scoped observeUser* subscription, so metadata + // loads for on-screen replies only and tears down when the thread closes. Column(modifier = Modifier.padding(top = 8.dp)) { // Comments card @@ -2022,24 +2040,30 @@ private fun ExpandedNoteContent( replyNotes.take(5).forEachIndexed { index, replyNote -> val replyEvent = replyNote.event val flowSet = remember(replyNote) { replyNote.flow() } - val metadataState by flowSet.metadata.stateFlow.collectAsState() val reactionsState by flowSet.reactions.stateFlow.collectAsState() val zapsState by flowSet.zaps.stateFlow.collectAsState() DisposableEffect(replyNote) { onDispose { replyNote.clearFlow() } } - val author = - remember(replyEvent?.pubKey, metadataState) { - replyEvent?.pubKey?.let { localCache.getUserIfExists(it) } - } + // Load this reply's own interactions (reactions/zaps) only + // while the comment row is composed. + EventFinderFilterAssemblerSubscription(replyNote) + + // Load + observe this reply author's metadata only while the + // comment row is composed; observeUser* both subscribes and + // drives recomposition when kind-0 arrives. + val replyAuthorPubKey = replyEvent?.pubKey + val author = remember(replyAuthorPubKey, localCache) { replyAuthorPubKey?.let { localCache.getOrCreateUser(it) } } + val authorName = author?.let { observeUserName(it).value } + val authorPicture = author?.let { observeUserPicture(it).value } val reactionCount = remember(reactionsState) { replyNote.countReactions() } val zapAmount = remember(zapsState) { replyNote.zapsAmount } CommentItem( - authorName = author?.toBestDisplayName() ?: replyEvent?.pubKey?.take(8) ?: "", + authorName = authorName ?: replyAuthorPubKey?.take(8) ?: "", authorHandle = author?.pubkeyNpub()?.take(16)?.let { "@$it..." } ?: "", - authorAvatarUrl = author?.profilePicture(), - authorPubKeyHex = replyEvent?.pubKey ?: "", + authorAvatarUrl = authorPicture, + authorPubKeyHex = replyAuthorPubKey ?: "", content = replyEvent?.content ?: "", timeAgo = (replyEvent?.createdAt ?: 0L).toTimeAgo(), reactionCount = reactionCount, diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/NotificationsScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/NotificationsScreen.kt index be0adf4ad5..4bdb8f1083 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/NotificationsScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/NotificationsScreen.kt @@ -67,6 +67,8 @@ import com.vitorpamplona.amethyst.commons.moderation.notifications.NotificationK import com.vitorpamplona.amethyst.commons.moderation.notifications.PreferencesNotificationReadState import com.vitorpamplona.amethyst.commons.moderation.notifications.PreferencesNotificationSettings import com.vitorpamplona.amethyst.commons.moderation.notifications.nowEpochSeconds +import com.vitorpamplona.amethyst.commons.relayClient.user.observeUserName +import com.vitorpamplona.amethyst.commons.relayClient.user.observeUserPicture import com.vitorpamplona.amethyst.commons.state.EventCollectionState import com.vitorpamplona.amethyst.commons.ui.components.EmptyState import com.vitorpamplona.amethyst.commons.ui.components.LoadingState @@ -654,10 +656,11 @@ private fun AggregateCard( horizontalArrangement = Arrangement.spacedBy((-4).dp), ) { reactorPubKeys.take(5).forEach { pk -> - val user = remember(pk, metadataVersion) { localCache.getUserIfExists(pk) } + val user = remember(pk, localCache) { localCache.getOrCreateUser(pk) } + val picture by observeUserPicture(user) UserAvatar( userHex = pk, - pictureUrl = user?.profilePicture(), + pictureUrl = picture, size = 20.dp, modifier = Modifier.clickable { onNavigateToProfile(pk) }, ) @@ -694,15 +697,17 @@ private fun AggregateCard( .clickable { onNavigateToProfile(pk) } .padding(vertical = 3.dp), ) { - val user = remember(pk, metadataVersion) { localCache.getUserIfExists(pk) } + val user = remember(pk, localCache) { localCache.getOrCreateUser(pk) } + val picture by observeUserPicture(user) + val name by observeUserName(user) UserAvatar( userHex = pk, - pictureUrl = user?.profilePicture(), + pictureUrl = picture, size = 22.dp, ) Spacer(Modifier.size(6.dp)) Text( - user?.toBestDisplayName() ?: pk.take(12), + name, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurface, ) @@ -821,14 +826,17 @@ fun NotificationCard( // actual zap sender lives in the nested zap request. effectiveAuthorPubKey // returns the right one per kind. val pk = notification.effectiveAuthorPubKey - val user = remember(pk, metadataVersion, localCache) { localCache?.getUserIfExists(pk) } + // Load + observe the actor's metadata only while this card is composed. + // localCache is only null in previews/defaults; guard the observers so we + // never touch LocalUserFinder off the provider tree. + val user = remember(pk, localCache) { localCache?.getOrCreateUser(pk) } + val observedName = user?.let { observeUserName(it).value } + val observedPicture = user?.let { observeUserPicture(it).value } val displayName = - remember(user, metadataVersion, pk) { - user?.toBestDisplayName() - ?: pk.hexToByteArrayOrNull()?.toNpub()?.take(12) - ?: pk.take(12) - } - val pictureUrl = remember(user, metadataVersion) { user?.profilePicture() } + observedName + ?: pk.hexToByteArrayOrNull()?.toNpub()?.take(12) + ?: pk.take(12) + val pictureUrl = observedPicture val unread by remember(notification.timestamp, lastReadAt) { derivedStateOf { notification.timestamp > lastReadAt } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/SearchScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/SearchScreen.kt index ba976472ea..cbddc13117 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/SearchScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/SearchScreen.kt @@ -349,19 +349,12 @@ fun SearchScreen( } } - // Load author metadata for incoming note results. NIP-50 search relays - // typically don't return kind-0 metadata alongside notes, and the - // subscription explicitly drops MetadataEvents anyway — so display name - // and avatar arrive only after we explicitly fetch them from index - // relays via the coordinator. - LaunchedEffect(noteResults, subscriptionsCoordinator) { - val coordinator = subscriptionsCoordinator ?: return@LaunchedEffect - if (noteResults.isEmpty()) return@LaunchedEffect - val authors = noteResults.map { it.pubKey }.distinct() - if (authors.isNotEmpty()) { - coordinator.loadMetadataBatched(authors) - } - } + // Note-result author metadata (kind 0) is no longer batch-fetched here. + // Each result renders through NoteCard, which opens its own composition-scoped + // UserFinderFilterAssembler subscription — so the author's metadata is fetched + // from index/outbox relays per on-screen result and torn down when scrolled off. + // (NIP-50 search relays don't return kind-0 and the subscription drops + // MetadataEvents; the per-row finder covers that gap.) // Fetch interactions (incl. kind-1018 poll responses) for poll results so their // tallies populate — NIP-50 search returns the polls but not their responses. diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ThreadScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ThreadScreen.kt index ecbd160bc1..dad4160970 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ThreadScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ThreadScreen.kt @@ -39,7 +39,6 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -194,12 +193,10 @@ fun ThreadScreen( onDispose { subId?.let { coordinator.releaseInteractions(it) } } } - // Load metadata for thread authors via coordinator - LaunchedEffect(threadNotes, subscriptionsCoordinator) { - if (subscriptionsCoordinator != null && threadNotes.isNotEmpty()) { - subscriptionsCoordinator.loadMetadataForNotes(threadNotes) - } - } + // Thread-author metadata + note interactions now load per row: each thread + // note renders through NoteCard, which opens composition-scoped UserFinder + + // EventFinder subscriptions. (requestInteractions above remains the thread's + // explicit interaction-refresh path.) // Fetch quoted notes referenced in thread content val quotedNoteIds = diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/ChatroomHeader.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/ChatroomHeader.kt index 96a771e6d3..3a3b724d41 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/ChatroomHeader.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/ChatroomHeader.kt @@ -29,12 +29,15 @@ import androidx.compose.foundation.layout.padding import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.commons.model.User +import com.vitorpamplona.amethyst.commons.relayClient.user.observeUserName +import com.vitorpamplona.amethyst.commons.relayClient.user.observeUserPicture import com.vitorpamplona.amethyst.commons.resources.Res import com.vitorpamplona.amethyst.commons.resources.accessibility_user_avatar import com.vitorpamplona.amethyst.commons.ui.components.UserAvatar @@ -59,6 +62,10 @@ fun ChatroomHeader( modifier: Modifier = ChatStdPadding, onClick: () -> Unit, ) { + // Load + observe the partner's metadata only while this header is composed. + val picture by observeUserPicture(user) + val name by observeUserName(user) + Column( Modifier .fillMaxWidth() @@ -68,14 +75,14 @@ fun ChatroomHeader( Row(verticalAlignment = Alignment.CenterVertically) { UserAvatar( userHex = user.pubkeyHex, - pictureUrl = user.profilePicture(), + pictureUrl = picture, size = ChatSize34dp, contentDescription = stringResource(Res.string.accessibility_user_avatar), ) Column(modifier = Modifier.padding(start = 10.dp)) { Text( - text = user.toBestDisplayName(), + text = name, style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.Bold, maxLines = 1, @@ -107,7 +114,12 @@ fun GroupChatroomHeader( modifier: Modifier = ChatStdPadding, onClick: () -> Unit, ) { - val participants = users.joinToString(", ") { it.toBestDisplayName() } + // Load + observe each participant's metadata only while this header is + // composed, so names and the group-icon avatar update as kind-0 arrives. + // forEach is inline, so the @Composable observeUserName call is legal here. + val participantNames = mutableListOf() + users.forEach { participantNames.add(observeUserName(it).value) } + val participants = participantNames.joinToString(", ") Column( modifier = Modifier @@ -121,9 +133,10 @@ fun GroupChatroomHeader( Row(verticalAlignment = Alignment.CenterVertically) { // Show first user's avatar as the group icon users.firstOrNull()?.let { firstUser -> + val firstUserPicture by observeUserPicture(firstUser) UserAvatar( userHex = firstUser.pubkeyHex, - pictureUrl = firstUser.profilePicture(), + pictureUrl = firstUserPicture, size = ChatSize34dp, contentDescription = stringResource(Res.string.accessibility_user_avatar), ) diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/ConversationListPane.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/ConversationListPane.kt index 337b738e8b..3ada46ff4d 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/ConversationListPane.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/ConversationListPane.kt @@ -66,6 +66,7 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.commons.relayClient.user.observeUserPicture import com.vitorpamplona.amethyst.commons.ui.components.UserAvatar import com.vitorpamplona.amethyst.desktop.ui.components.ToggleableTimeAgoText import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey @@ -320,9 +321,12 @@ private fun ConversationCard( val firstUser = item.users.firstOrNull() Box { if (firstUser != null) { + // Load + observe the conversation's primary user only while this + // row is composed (i.e. on screen in the list). + val firstUserPicture by observeUserPicture(firstUser) UserAvatar( userHex = firstUser.pubkeyHex, - pictureUrl = firstUser.profilePicture(), + pictureUrl = firstUserPicture, size = 40.dp, ) } else if (item.isGroup) { diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/note/NoteCard.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/note/NoteCard.kt index adf0b732ca..27d6ae795a 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/note/NoteCard.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/note/NoteCard.kt @@ -56,6 +56,8 @@ import androidx.compose.ui.unit.dp import coil3.compose.AsyncImage import com.vitorpamplona.amethyst.commons.model.EmptyTagList import com.vitorpamplona.amethyst.commons.model.ImmutableListOfLists +import com.vitorpamplona.amethyst.commons.relayClient.event.EventFinderFilterAssemblerSubscription +import com.vitorpamplona.amethyst.commons.relayClient.user.UserFinderFilterAssemblerSubscription import com.vitorpamplona.amethyst.commons.richtext.CachedRichTextParser import com.vitorpamplona.amethyst.commons.richtext.RichTextParser import com.vitorpamplona.amethyst.commons.richtext.UrlParser @@ -63,6 +65,7 @@ import com.vitorpamplona.amethyst.commons.ui.note.ReplyContext import com.vitorpamplona.amethyst.commons.ui.note.ReplyToLabel import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache import com.vitorpamplona.amethyst.desktop.ui.components.ToggleableTimeAgoText +import com.vitorpamplona.amethyst.desktop.ui.deck.LocalDesktopCache import com.vitorpamplona.amethyst.desktop.ui.media.AnimatedGifImage import com.vitorpamplona.amethyst.desktop.ui.media.AudioPlayer import com.vitorpamplona.amethyst.desktop.ui.media.DesktopVideoPlayer @@ -112,6 +115,25 @@ fun NoteCard( replyContext: ReplyContext? = null, onNavigateToThread: ((String) -> Unit)? = null, ) { + // Load the author's metadata (kind 0) only while this card is composed — + // i.e. on/near screen. This one call covers every screen that renders + // through NoteCard (profile, thread, bookmarks, search); the shared commons + // finder coalesces all visible authors into batched REQs. + val noteCardCache = LocalDesktopCache.current + val noteCardAuthor = remember(note.pubKeyHex, noteCardCache) { noteCardCache?.getOrCreateUser(note.pubKeyHex) } + if (noteCardAuthor != null) { + UserFinderFilterAssemblerSubscription(noteCardAuthor) + } + + // Load this note's interactions (reactions / zaps / reposts / replies) only + // while the card is composed — covers every NoteCard surface (profile, thread, + // bookmarks, search, quoted embeds). Guarded on cache presence so previews + // (no LocalDesktopCache → no LocalEventFinder) don't hit the provider default. + val noteCardNote = remember(note.id, noteCardCache) { noteCardCache?.getNoteIfExists(note.id) } + if (noteCardNote != null) { + EventFinderFilterAssemblerSubscription(noteCardNote) + } + val urls = remember(note.content) { UrlParser().parseValidUrls(note.content) } val imageUrls = remember(urls) { diff --git a/docs/changelog/translators.json b/docs/changelog/translators.json index 0697190820..6364d560b3 100644 --- a/docs/changelog/translators.json +++ b/docs/changelog/translators.json @@ -121,6 +121,8 @@ "user": "davotoula", "languages": [ "Czech", + "German", + "Portuguese, Brazilian", "Swedish" ] }, diff --git a/docs/concord-banlist-rank-conformance.md b/docs/concord-banlist-rank-conformance.md index afbe98fdbd..d48623c113 100644 --- a/docs/concord-banlist-rank-conformance.md +++ b/docs/concord-banlist-rank-conformance.md @@ -1,7 +1,8 @@ # Concord: the Banlist is not rank-gated in any implementation (CORD-04 conformance) **Status:** conformance bug. Reproduced in Amethyst and **fixed there** (see §6); present by -inspection in Armada. +inspection in Armada. Finding #3, left open in §4 as a fixpoint-ordering question, is now also +implemented in Amethyst — see the 2026-08-09 update before §Rollout status. **Severity:** privilege escalation. Any `BAN` holder can neutralise every authority above them, including the owner. **Reported by:** Amethyst (MIT), 2026-07-20. Findings verified by unit test; see "Evidence" below. @@ -190,6 +191,61 @@ We'd also suggest **§4 restating the rank half inline**, the way §2 does for G does for Kicks. Both independent implementations read §4 in isolation and both got it wrong the same way; that is strong evidence the section is the problem, not the readers. +### Update, 2026-08-09: we have now implemented #3 + +Amethyst now answers the ordering question rather than leaving it open, because #3 turned out to be +the doorway to a full community takeover and not merely an inconsistency — a banned staffer who kept +`control_root` kept the entire roster, and could mint a fresh un-banned npub that passed every +ban-aware gate. The write-up is `docs/concord-soft-ban-audit.md` (B2). + +The rule we shipped: **authority only ever shrinks, over two passes.** Pass A resolves exactly as +before and yields a candidate banlist; pass B re-resolves with every author on that list treated as +holding no authority at all, for roles, grants and the Banlist alike. Two passes, always, so it +terminates by construction. It cannot oscillate on mutual bans either, because the rank rule makes +them unreachable: only a member who strictly outranks you may ban you, and you cannot outrank them +back. + +Note what it costs, because it is not obvious and you would hit it too: this **cascades**. Every +edition a banned member ever authored is dropped, grants included, so banning an admin also demotes +everyone that admin promoted. We think that is the literal reading of §4 and it is what kills the +sockpuppet — but a legitimate promotion by a later-banned admin vanishes with it, and the owner has +to re-issue it. If you read §4 as scoping only to editions authored *after* the ban, say so; that is +implementable too, but it needs the spec to define an ordering between an edition and a Banlist +entry, which today it does not. + +We checked your implementation before writing this, and you got there first: `foldControlState` +already runs the same two-pass, with the same §4 justification in the comment. So this is us catching +up, not diverging — with one narrower difference. You keep **pass 1's** Banlist as the final word; +we recompute it in pass 2. So a banned admin's mass-ban of everyone beneath them still stands for you +and is dropped by us. Your stated reason is to stop the anti-roster erasing itself; ours is that an +edition should not outlive its author's removal, and the self-erasure case is unreachable under the +rank rule anyway, since only a member who strictly outranks you can ban you. We would rather converge +than be right — tell us which way and we will move. + +Two more things that fell out of reading `src/concord-v2/` side by side, both worth their own look: + +- **`bootstrapHead` has no bound** (`lib/version.ts`), and `headCandidates` uses it for the + compaction arm while `pickHead` then raises the stored floor to whatever won. One authorized + edition at `version = 2^63 - 1` therefore becomes an entity's permanent head: the floor rises to + match, nothing honest can exceed it, and even a Refounding that drops the edition falls back to the + remembered head — which is that edition. It needs no ban and no sockpuppet, just one ordinary + permission bit. This is the second bug both implementations share by reading the same section the + same way; ours is described in `docs/concord-soft-ban-audit.md` (B1), and we bounded the jump the + arm will follow. +- **Your Banlist takes only the gated head's content**, with no §4 re-heal union. We union in every + authorized non-ancestor edition, which is what defeats an attempt to launder a ban away by forking + the list at genesis. So we honor concurrent bans you drop. Which is normative? + +A chain-local rule is not enough, and this is the trap worth flagging: "the author must not be banned +by the state their edition chains from" is bypassed by forking the Banlist at genesis, where no +parent ever mentions the ban and §4's re-heal union carries it in anyway. The rule has to bind the +union, which is why ours is a whole-pass mask rather than a per-edition check. + +This widens the divergence in §6: we now drop editions you honor in any community where a privileged +member was banned, not only where a signer failed to outrank their target. + +--- + Separately, please rule on **#3**: whether a banned npub's Banlist edition is honored. Our reading of §4 ("drops every event from a banned npub — message, reaction, edit, or authority action") is that it must not be, but the fixpoint ordering needs to be stated for that to be implementable consistently. diff --git a/docs/concord-soft-ban-audit.md b/docs/concord-soft-ban-audit.md new file mode 100644 index 0000000000..43848a1302 --- /dev/null +++ b/docs/concord-soft-ban-audit.md @@ -0,0 +1,583 @@ +# Concord: soft-ban and Control Plane audit + +**Scope:** what a removed member — or a moderator who turns — can still do to a Concord community. +**Date:** 2026-08-09. **Status:** A1–A4, B1, B2 and B4 are **fixed** on this branch; the rest are +accepted, deferred to the spec, or belong to other people's relays. Each section carries its own +status line. +**Companion:** `docs/concord-banlist-rank-conformance.md` (the rank half of CORD-04 §4, already +reported to Armada and fixed here). + +Each finding says how it was established. **Verified** means a test in this repo reproduces it; +**Read** means it follows from the code but no test was written. Every "Verified" line names the test. + +--- + +## How to read this list + +Findings are split by **what the attacker needs**, because that decides who owns the fix and how +urgent it is: + +- **[Part A — reachable from stock Amethyst](#part-a).** A banned user opens the shipping app and + taps a button, or our own client does it for them on a timer. These are straightforwardly *our + bugs*, they need no attacker sophistication at all, and every one of them is fixable in this repo + without touching the protocol or coordinating with anyone. +- **[Part B — requires a malicious client](#part-b).** The attacker writes their own events, so no + client-side rule binds them. We cannot stop them from *authoring* anything; we can only refuse to + *honor* it. Fixes live in the fold, the store, or the spec. +- **[Part C — interop and not-yet-shipped surfaces](#part-c).** + +The distinction is not academic. Part A is where the realistic attacker is: an irritated user who +just got banned has the app already installed and is not going to write a Nostr client. Part B is +where the *damage ceiling* is. Fix Part A first because it is cheap and it is what will actually +happen; fix Part B because it is what ends communities. + +Two structural causes account for most of both halves: + +- **Authority is checked in several places that disagree.** `ConcordCommunityState.fold` gates + METADATA/CHANNEL/INVITE through the ban-aware `authority.hasPermission`. `AuthorityResolver` + gates ROLE/GRANT/BANLIST internally through `holdsManageRoles` / `bitsOf` / + `effectivePermissionsOf`, which are ban-blind. The **UI** gates through `effectivePermissions`, + also ban-blind. The **action layer** mostly does not gate at all. Same question, four answers. +- **A ban removes standing, never keys.** `community_root`, channel keys, `control_root` if staff, + and live invite links all survive it. Only a CORD-06 Refounding rotates those — which is why + anything that makes Refounding expensive (B4) or reversible (A2) is worth more to an attacker + than it first looks. + +--- + +## Summary + +### Part A — reachable from stock Amethyst (our bugs) + +| # | Finding | Severity | Status | +|---|---------|----------|--------| +| [A1](#a1) | Any member — banned included — mints a working invite in one tap | **Critical** | **Fixed** | +| [A2](#a2) | Stranded recovery runs on a timer and never checks the banlist | **Critical** | **Fixed** (security half; liveness half open) | +| [A3](#a3) | The action layer has no permission checks; the UI's are ban-blind | High | **Fixed** | +| [A4](#a4) | A banned member keeps broadcasting "typing", and we keep showing it | Low | **Fixed** | +| [A5](#a5) | A banned member's own client keeps reading and rendering everything | Medium | Inherent — product decision | + +### Part B — requires a malicious client + +| # | Finding | Severity | Needs a ban? | Status | +|---|---------|----------|--------------|--------| +| [B1](#b1) | One edition at `version = Long.MAX_VALUE` pins an entity forever | **Critical** | No — any bit-holder | **Fixed** | +| [B2](#b2) | A banned staffer keeps Role/Grant/Banlist authority | **Critical** | Yes | **Fixed** (matches Armada) | +| [B3](#b3) | A rogue rotator compacts the banlist away | High | Via B2 | **Mitigated** by B2 | +| [B4](#b4) | The Refounding recipient set is attacker-inflatable | High | No | **Fixed** (bounded) | +| [B5](#b5) | The ban is per-pubkey; the channel key is not revoked | High | Yes | Inherent — Refounding is the answer | +| [B6](#b6) | Channel history is deletable on a naive third-party relay | High | Yes | Correct here; external relays at risk | +| [B7](#b7) | The base-rekey plane is writable by every member | Low | Yes | Accepted | + +### Part C — interop and not-yet-shipped + +| # | Finding | Severity | Status | +|---|---------|----------|--------| +| [C1](#c1) | Banlist rank rule diverges from Armada | Medium | Reported; B2 widens the divergence | +| [C2](#c2) | CORD-07 voice rooms are key-gated, not roster-gated | Design | Note for whoever ships voice | + +--- + +# Part A — reachable from stock Amethyst + +No custom tooling. A banned user with the shipping app, or our own background sweep. + +## A1 — Any member, banned included, mints a working invite in one tap + +**Status: fixed.** `mintConcordInvite` and its button now require `CREATE_INVITE` (or ownership). + + +**Critical. The single most likely thing an irritated banned user actually does.** +*Read:* `AccountConcordActions.mintConcordInvite`, `ConcordChannelListScreen` (the `PersonAdd` +`IconButton`). + +`mintConcordInvite` checks exactly two things: that the account is writeable, and that we have the +community in our joined list. **No `CREATE_INVITE` check. No banlist check.** And unlike the Edit +and channel-management buttons beside it, the invite `IconButton` is rendered with no `canEdit` +guard at all — it is always there, for everyone. + +So the flow is: get banned, stay in the app, tap the person-add icon, share the link. The minted +bundle carries the community root we still hold, so anyone who opens it joins for real. Every +invited account is a fresh unbanned npub that moderators then have to ban one at a time. + +Two aggravating details. The mint publishes a **fresh link signer per invite**, so it is a brand-new +coordinate — revoking the links the banned member was given does not touch the ones they mint. +And `CREATE_INVITE` is a real permission bit that the fold enforces on `INVITE_*` Control entities, +but the actual invite mechanism is a standalone kind-33301 addressable event published *outside* the +Control Plane, so that gate never applies to it. The permission is, in practice, unenforced. + +**Fix.** Gate `mintConcordInvite` on `hasPermission(me, CREATE_INVITE) || isOwner(me)`, and gate the +button on the same. This is contained, uncontroversial, and closes the realistic attack. Do it first. + +## A2 — Stranded recovery runs on a timer and never checks the banlist + +**Status: security half fixed; liveness half open — and the fork is now resolved.** `isStranded` / +`mergeForward` take `bannedAtCurrentEpoch` as a *required* argument, so a removed member is no longer +walked back in. The open question was whether anything re-mints at a stable coordinate. **Armada +does** — `useLinkRefreshWatch2` re-posts every bundle on each epoch change — so in any cross-client +community this was a *live* removal bypass, not a hypothetical, and the fix was load-bearing. The +liveness half stands: Amethyst re-mints nothing, so legitimate recovery never fires for an +Amethyst-only community. See [the Armada comparison](#armada) for the two ways out. + + +**Critical, and it forks.** *Read:* `ConcordStrandedRecovery`, +`AccountConcordActions.recoverStrandedConcordCommunities`, `AccountConcordActions.mintConcordInvite`. + +This is in Part A because **our own client performs it, unprompted**: the recovery sweep runs on the +revision tick for every joined community holding an `inviteRef`, every 15 minutes. The banned user +does nothing but leave the app installed. + +`ConcordStrandedRecovery.isStranded` / `mergeForward` take only `(entry, bundle)` — no banlist +check, no check that we were legitimately re-keyed. The whole test is "the bundle at my stored +`inviteRef` sits at a higher epoch than I do", and the unlock token lives in the link fragment an +ex-member keeps forever. So whether a removed member walks back in depends *only* on whether +anything re-mints at that coordinate: + +- **If nothing re-mints** — today, since Amethyst mints a fresh link signer per invite and the + Refounding neither re-mints nor revokes — stranded recovery never fires for anyone. It is dead + code, and the cure `drainConcordRekeys`' own KDoc points to for "a BAN-holder can evict anyone + (the owner included) by omission" does not exist. An owner evicted by a rogue admin has no way back. +- **If anything re-mints at a stable coordinate** — which is what CORD-05's design describes, so + plausibly Armada in a cross-client community — every removed member auto-recovers the new root and + re-announces a Guestbook join, looking current again. **The only hard removal is silently undone.** + +Note also that `refoundConcordCommunity` never revokes the links the removed member created or +joined through, though `ControlEntityKind.INVITE_REVOKED` exists and `classifyInvite` honors it. + +**Fix.** Decide the intended semantics first — this needs a spec answer. Then gate `mergeForward` on +not being banned in the epoch we merge *from*, have the Refounding revoke the removed members' +links, and either implement re-minting so legitimate recovery works, or drop the mechanism and give +evicted owners another route. + +## A3 — The action layer has no permission checks; the UI's are ban-blind + +**Status: fixed.** Authority now lives in `AccountConcordActions.isAuthorizedFor`, which every +moderation verb funnels through, and every authorization test uses the ban-aware `hasPermission`. + + +**High (defense in depth).** *Read:* `AccountConcordActions` (`banConcordMember`, +`unbanConcordMember`, `editConcordMetadata`, `deleteConcordChannel`, `refoundConcordCommunity`), +`ConcordMembersScreen`, `ConcordChannelListScreen`. + +Every moderation verb checks `isWriteable()` and the Control write key, and **nothing else** — no +permission bit, no banlist. Authority lives entirely in the composable that draws the button. Two +consequences: + +1. **The UI's own gates are ban-blind.** `iCanBan`, `canEdit` (metadata) and `canManageChannels` all + use `effectivePermissions`, which ignores the banlist. A banned admin still sees the Edit and + channel-management controls. Those particular editions are dropped by every client's fold + (METADATA/CHANNEL are `hasPermission`-gated), so the result is a **silently no-op control** — + which this codebase elsewhere explicitly calls out as worse than no control at all. +2. **Ban/Remove survive only because of a second, unrelated gate.** `canBan` is + `viewerCanBan && canBanTarget`, and `canBanTarget` routes through `canActOn`, which *is* + ban-aware. Remove the second condition and a banned admin gets a working Ban button. That is a + thin margin for a Critical-severity outcome (B2). + +`refoundConcordCommunity` is the sharpest instance: its own guard is +`isOwner || effectivePermissions(me).has(BAN)` — deliberately ban-blind — so a banned BAN-holder can +launch a full community Refounding from the shipping app. Honest receivers refuse it +(`drainConcordRekeys` checks the ban-aware `hasPermission`), so the blast radius today is noise plus +self-stranding — but it is a race against banlist propagation, and a fresh joiner who has not folded +the ban yet has no reason to refuse. + +**Fix.** Move the authority check into the action layer where it cannot be bypassed by a new caller +(desktop, CLI, a future screen), and switch every `effectivePermissions` used as an authorization +test to `hasPermission`. Keep `effectivePermissions` only where the question really is "what do +their roles say", independent of standing. + +## A4 — A banned member keeps broadcasting "typing", and we keep showing it + +**Status: fixed on both ends.** + + +**Low, both halves ours.** *Read:* `AccountConcordActions.sendConcordTyping`, +`ConcordCommunitySession.ingestTyping`. + +The send side checks `isWriteable()` and nothing else, so a banned member's stock app keeps emitting +kind-23311 heartbeats. The receive side checks that the rumor is a typing heartbeat, is bound to the +channel/epoch, and is not our own — and nothing else. So a banned member sits in the "… is typing" +row indefinitely, in a channel where every message they send is hidden. Cheap to fix on both ends, +and it directly contradicts what a ban promises the user. + +## A5 — A banned member's own client keeps reading and rendering everything + +**Status: inherent; no code change.** The cryptography cannot be fixed without a Refounding, so what +is left is a product decision about how "Ban" and "Remove from community" are presented. Left for a +design pass rather than guessed at here. + + +**Medium, partly inherent.** *Read:* CORD-02/05, `ConcordCommunitySession`. + +Until a Refounding, a ban stops honest clients from *showing* the banned member's posts; it does not +stop delivering the community's posts *to* them. Their stock app keeps subscribing, decrypting and +rendering the whole community in real time. They also keep any invite links they hold (and can mint +more — A1). + +The cryptography here is inherent to a soft ban, but the **product** side is ours: "Ban" and "Remove +from community" are very different promises and the UI presents them as neighbours in one menu. +Worth making the difference explicit at the point of choice, and worth defaulting destructive +moderation to the Refounding path. + +--- + +# Part B — requires a malicious client + +The attacker writes their own events, so nothing client-side binds them. We can only refuse to honor +what they publish. + +## B1 — One edition at `Long.MAX_VALUE` pins an entity forever + +**Status: fixed.** The compaction arm tries the floor-anchored chain first and bounds the bootstrap +jump at `EditionFold.MAX_COMPACTION_VERSION_JUMP`; `compactControlPlane` picks the chain head rather +than raw max version. The three reproductions now assert the fixed behaviour, and +`aGenuineCompactionJumpIsStillFollowed` pins the CORD-06 §3 tolerance the bound must not break. + + +**Critical. Does not require a banned user, a sockpuppet, or the owner's absence. Unrecoverable.** + +*Verified:* `quartz/…/cord04Roles/ControlPlaneVersionExhaustionTest.kt` (3 tests). + +Any current holder of an entity's permission bit publishes one edition at `version = +Long.MAX_VALUE`. For every client that holds an `EntityFloor` for that entity, that edition becomes +the permanent head: + +1. it wins, so the entity shows the attacker's content; +2. `authorizedHeads` raises the entity's floor to `Long.MAX_VALUE`; +3. no honest edition can ever exceed that floor, so the entity can never be repaired; +4. a Refounding that drops the poison does not help — nothing is offered at or above the floor, the + fold reports a gap, and falls back to `EntityFloor.known`, which *is* the poison. + +The chain walk is not the weakness (it advances only to `head.version + 1` citing the head's hash, +so a fresh joiner is unaffected). The weakness is the **compaction arm** of `EditionFold.foldEntity`: +once a floor exists and the entity is in the epoch snapshot — which `fold` always builds from the +editions handed to it — the head comes from `bootstrapHead`, i.e. *highest version at or above the +floor*, with no `prev`, no hash, no contiguity. Version becomes the whole contest. + +Concretely: a moderator with `MANAGE_CHANNELS` deletes `#general` permanently for everyone; one +with `MANAGE_METADATA` renames the community permanently. Demoting or banning them afterwards +changes nothing — the damage is in every client's floor. `ConcordRefounding.compactControlPlane` +also selects the head per entity by raw highest version, ungated, so an honest rotator carries the +poison into every future epoch, where fresh joiners then anchor on it as their baseline. + +The banlist survives, by accident: `AuthorityResolver` folds it on its own floor-less chain walk and +re-heals the union across authorized editions, so an honest ban still lands. That accident is the +only thing separating this from a permanently unmoderatable community, and it is now pinned by +`aPoisonedBanlistStillAcceptsTheOwnersBan`. + +**Fix direction.** The compaction arm needs a bound, since it is the arm that trades contiguity for +cross-epoch tolerance. Options, roughly in order of preference: + +- Cap the version delta the arm will accept in one step (a compacted head is legitimately ahead of + the floor, but by a chain's worth, not by 2^63). Anything above the cap is a gap, not a head. +- Make `bootstrapHead` prefer the highest version *reachable by a chain* among the offered editions, + falling back to raw version only when no chain connects. +- Have `compactControlPlane` select the authority-gated fold head rather than raw max version, so a + poison is at least not propagated by honest rotators. + +The first is the smallest change and closes the unrecoverability; the third should happen regardless. + +## B2 — A banned staffer keeps Role, Grant and Banlist authority + +**Status: fixed. Not consensus-affecting after all** — see [Armada comparison](#armada). Armada +already implements the same two-pass, so this brings us *into* line rather than out of it. One +narrower divergence remains, described there. `AuthorityResolver.resolve` is now a bounded two-pass +where authority only shrinks. Note the deliberate cascade it brings: every edition a banned member +ever authored is dropped, so banning an admin also demotes everyone that admin promoted. That is the +literal reading of CORD-04 §4 and it is what kills the sockpuppet, but a legitimate promotion by a +later-banned admin vanishes with it and has to be re-issued. + +**Critical.** *Verified:* `quartz/…/cord04Roles/BannedStaffEscalationTest.kt` (13 tests). + +`hasPermission` is ban-aware; the resolver's internal gates are not, and structurally cannot be as +written — the roles/grants fixpoint settles before `banned` is computed. So a banned member who +still holds `control_root` keeps the roster. In the reproduction they: + +- ban every member they outrank, directly, with no puppet; +- revoke the surviving moderators' grants and retire the roles beneath them; +- **mint a fresh, unbanned npub** at the next position down, which then passes every ban-aware gate: + deletes every channel, rewrites the metadata, bans the rest of the community, creates invites; +- and, because `drainConcordRekeys` authorizes a rotator by `hasPermission(rotator, BAN)`, that + puppet can publish a Refounding omitting the owner — every honest client follows it and the owner + is stranded on a dead root. + +Self-unban is *not* reachable and neither is a puppet-unban: the delta rule gates removals and +strict outranking means nobody outranks themselves, while no edition may claim a position at or +above its signer, so the delegation chain only descends. Two hand-crafted attempts that avoid +removal entirely — forking the banlist at genesis, and building a private chain — are also refused, +by CORD-04 §4's re-heal union rather than by the rank rule. **The union is load-bearing security +here, not just convergence.** + +**Fix direction.** Make the resolver's gates ban-aware. The ordering problem is real (you cannot +know who is banned before folding the banlist, nor who may write it before knowing who is banned), +so resolve it as a bounded two-pass where authority only ever *shrinks*: pass A settles the roster +as today and computes the banlist; pass B re-resolves roles/grants dropping editions whose author is +banned in pass A; then recompute the banlist under pass B's roster, keeping only bans still +authorized. Deterministic, terminates, no oscillation on mutual bans. **Consensus-affecting**: until +Armada ships the same rule, we will drop editions they honor. + +## B3 — A rogue rotator compacts the banlist away + +**Status: mitigated by B2.** The rotator this needed was the sockpuppet, which can no longer be +minted. A *legitimately* privileged rotator can still omit the banlist, and `EntityFloor` remains the +only defense for clients that already folded it — unchanged, and still worth a spec fix. + + +**High.** *Verified:* `aRogueRotatorCompactsTheBanAwayForEveryClientWithoutAFloor`. + +A CORD-06 §3 compaction re-wraps one edition per entity and the *rotator* picks it, so a rotator can +decline to carry the banlist forward. Every edition it serves is genuine, so no signature check sees +the omission — `EntityFloor`'s own KDoc names this case ("clearing a banlist"). A banned member +cannot rotate, but the B2 puppet can. + +The result is not a clean unban but a **split community**: clients that already folded the ban +refuse the rollback and still see it, fresh joiners have no floor and see no ban at all. Two +populations permanently disagreeing about who is a member, with no event either side can call +forged. Closing B2 removes the puppet and takes this with it; floors alone do not, since they only +protect people who were already there. + +## B4 — The Refounding recipient set is attacker-inflatable + +**Status: fixed (bounded).** The recipient set is capped, the owner-rooted roster is kept first, and +anything dropped is logged rather than silently truncated. + + +**High.** *Read:* `ConcordCommunitySession.allMembers()` / `emitChannelRumors`; +`AccountConcordActions.refoundConcordCommunity` step 2; `ConcordRefounding.buildBaseRekeyWraps`. + +`allMembers()` = Guestbook joins ∪ `observedAuthors` ∪ roster ∪ owner, and it *is* the Refounding +recipient set. Both contributing sets are unbounded and both are attacker-writable: Guestbook joins +are self-signed (any key, no authority), and every author we decrypt is folded into +`observedAuthors` by design (CORD-02 §5, "observably present"). + +So each throwaway npub an attacker posts from, or announces, is one more mandatory NIP-44 blob in +the next Refounding, chunked 120 per event. 100k identities ⇒ ~100k encryptions and ~830 published +events — while they keep posting. **The attack inflates the cost of its own remedy**, and the remedy +is the only hard removal Concord has. + +This is the cheapest thing on the list to fix and the only one that is not consensus-affecting: cap +the recipient set, prefer recent/attested members when over the cap, and surface what was dropped +(a silent truncation strands real members). Worth doing first. + +## B5 — The ban is a per-pubkey display rule and the channel key is not revoked + +**Status: inherent.** No client-side fix exists; a Refounding is the answer, which is why B4 mattered. + + +**High.** *Read:* `Account.consumeConcordRumorGated` (`isBanned(rumor.pubKey)`), `Account.isAcceptable`. + +Writing to a channel needs the channel key, which the ban does not take away; the seal author is +whatever key the client feels like using. A malicious client therefore posts every message from a +fresh npub and `isBanned` never matches — moderation is whack-a-mole against an infinite identity +supply. Each message also costs every member two NIP-44 decrypts and two signature verifications +*before* the banlist check runs, and each fresh author inflates B4. + +There is no client-side answer; only a Refounding rotates the key out from under them. That is the +correct design, which is why B4 matters so much. + +## B6 — Channel history is deletable on a naive third-party relay + +**Status: correct on our relay and pinned; external relays remain exposed.** Needs a CORD-01 spec note +and relay-selection guidance, not code. + + +**High, external.** *Verified (that we are safe):* +`geode/…/ConcordPlaneKeyDeletionTest.kt` (3 tests). + +CORD-01 signs every wrap with the shared stream key, so on the wire a Concord channel is one author +publishing everything — and every member holds that author's secret. NIP-09 and NIP-62 authorize on +the outer `pubkey`. Read the obvious way, that hands any ex-member a one-event wipe of the whole +community's history, and geode's own guarantee ("a kind-5 from pubkey X cannot delete pubkey Y's +events") is vacuous inside a plane. + +**On our relay it is refused, but only because of a rule written for something else:** +`Event.owner()` gives a kind-1059 to its *p-tag recipient* rather than its signer, and +`ConcordStreamEnvelope` stamps a freshly random p-tag on every wrap, so each wrap is owned by a +one-time key nobody holds. Both halves are load-bearing, neither was written for this, and either +one silently re-opens the hole — all three are now pinned, including a counterfactual showing a wrap +addressed to a *real* key is deletable by its holder. + +A community publishes wherever its metadata points. Any relay that authorizes deletion by matching +`pubkey` still hands every ex-member the wipe button, and a Refounding protects only the future. +Worth a note in the CORD-01 spec and a line in the relay-selection guidance. + +## B7 — The base-rekey plane is writable by every member + +**Status: accepted.** Bounded work per wrap, no correctness impact. + + +**Low.** *Read:* `ConcordKeyDerivation.baseRekeyAddress`, `AccountConcordActions.drainConcordRekeys`. + +The base-rekey address derives from `community_root`, so any member — banned included — can mint +valid wraps there. Authorization happens after the blobs are scanned, so a flood costs every member +a locator scan per blob on every revision tick. Bounded work per wrap and no correctness impact; +listed for completeness. + + +--- + +# Part C — interop and not-yet-shipped + +## C1 — Banlist rank rule diverges from Armada + +**Medium, known, deliberate.** See `docs/concord-banlist-rank-conformance.md`, already reported. + +We enforce §3's rank half on the Banlist and Armada does not, so the two clients can show different +banlists. Shipped knowingly. Row 3 of that report ("a banned `BAN` holder unbans themselves") was +left open as a fixpoint-ordering question — B2 is the general form of it, and the fix proposed there +resolves both. + +## C2 — Voice rooms are key-gated, not roster-gated + +**Design-level; not currently reachable.** *Read:* `ConcordBrokerToken`, CORD-07 §2. + +Downgraded from High on review: `ConcordBrokerToken` and `VoicePresence` are referenced nowhere +outside `quartz`, so Amethyst ships no Concord voice path yet. This is a note for whoever wires +one up, not a live hole. + +A member proves voice-room membership by signing a NIP-98 kind-27235 request with the channel's +**derived voice signer key**, whose pubkey is the SFU room name. The broker is stateless and holds +no community secret, so it cannot consult the Control Plane and has no idea a banlist exists. A +banned member keeps that key until a Refounding, so they can join the voice room and stay in it. +Nothing on the client side can evict them — kicking them from the UI does not kick them from the SFU. +It would be the one place where a ban fails *audibly*, in real time, in front of everyone, so it is +worth designing the roster check in before shipping rather than after. + + + +--- + +## Armada comparison (checked 2026-08-09) + +Read against `gitlab.com/soapbox-pub/armada` at `src/concord-v2/`. Worth doing before shipping any of +this, and it changed two conclusions. + +**B2 — they already do it, and we had it backwards.** `foldControlState` (`lib/control.ts`) runs the +same bounded two-pass: fold once, take the banlist, and if any edition was authored by someone on it, +re-fold with those editions excluded. Independently arrived at, same shape, same CORD-04 §4 +justification in the comment. So this change brings us *into* line with Armada rather than out of it, +and the consensus warning in the earlier revision of this doc was wrong. + +One real divergence remains, and it is ours to defend: Armada keeps **pass 1's** banlist as the final +word ("the first pass's Banlist stays the final word"), while we recompute the banlist in pass 2. So +a banned admin's mass-ban of everyone beneath them still stands in Armada and is dropped by us — the +`aBannedAdminBansEveryoneBeneathThemWithoutNeedingAPuppetAtAll` case. Their stated reason is to stop +the anti-roster erasing itself; ours is that an edition from a banned author should not survive its +own author's removal. Both are defensible; ours closes an attack theirs leaves open, and the +self-erasure they worry about is unreachable for us because the rank rule makes mutual bans +impossible (only someone who strictly outranks you can ban you). Worth raising with them. + +**B1 — the same bug, unfixed, in exactly the same place.** `bootstrapHead` (`lib/version.ts:155`) +takes the highest version at or above the floor with no bound; `headCandidates` uses it for the +compaction arm; `pickHead` then raises the stored floor to whatever won. That is the whole +version-exhaustion chain. This is now the second bug both implementations share because both read +the same section the same way, and it deserves the same treatment as the rank rule: a written report. + +**A1 — ours alone.** Armada gates invite creation on `CREATE_INVITE` in both the hook +(`useInvites2.ts`) and the page (`canCreateInvite`). We were the only client handing a banned member +a working invite button. + +**A2 — different architecture, and it is better.** Armada's catch-up is **push**, not pull: a +privileged member sends a stranded member a direct invite carrying the fresher root +(`useDirectInvites2`, `catchUp`), so a human authorizes each re-admission. `useRekeyWatch2` merely +reports `{ stranded: boolean }` for the UI. They also ship `useBanSelfRemove2`: a banned member's own +client silently drops the community from their private list — network-silent, deliberately narrower +than rekey-exclusion, because "a rotation can be a mistake; a ban is a judgment". Our pull-from-my-own- +old-link design is what made the bypass possible, and their per-epoch bundle refresh is what would +have supplied the higher epoch to pull. Two ways forward: adopt a refresh of our own (restores +liveness, keeps the pull design and its risk), or move to their push model (safer, and it is what the +one existing implementation does). + +**C1 — still open on their side.** `banlistGate` remains a bare `isAuthorized(roster, author, owner, +BAN)`: no rank check, no delta rule. The divergence from +`docs/concord-banlist-rank-conformance.md` is unchanged. + +**A divergence in the other direction.** Armada's banlist takes only the gated head's content — +there is no §4 re-heal union. Ours unions in every authorized non-ancestor edition, which is what +defeats the genesis-fork laundering attempt in `BannedStaffEscalationTest`. So we honor concurrent +bans they drop. Worth a spec question about which is normative. + +**A4 — shared gap.** No ban filter on typing there either. + +**B4 — not established.** I could not locate a recipient-set bound in their rekey path, but I also +could not locate the recipient-set construction itself with confidence, so treat this as unchecked +rather than as a finding either way. + + +--- + +## Performance of the fixes + +Measured on the JVM with a synthetic Control Plane (throwaway benchmark, not committed — +`ConcordCommunityState.fold` over 226 and 2059 editions, 200 reps after warmup). Pass A of the +two-pass resolver is byte-for-byte the old algorithm, so the single-pass rows below *are* the +before-numbers. + +| Case | µs / fold | +|---|---| +| 226 editions, no bans | 1457 | +| 226 editions, 20 bans, none of them authors | 994 | +| 226 editions, 20 bans, one an author → pass B runs | 1881 | +| 2059 editions, no bans | 2194 | +| 2059 editions, 50 bans, none of them authors | 2001 | +| 2059 editions, 50 bans, one an author → pass B runs | 5697 | +| 2059 editions, with floors (B1's compaction arm) | 2015 | + +Two things to take from it. + +**B1 costs nothing measurable.** Trying the floor-anchored chain before the raw-version bootstrap +adds a per-entity version index on the compaction arm, but an entity carries a handful of editions, +and the floored fold measures the same as the unfloored one. + +**B2 costs a further fold, but only when it can change the answer.** `resolve` skips pass B when +nobody is banned *or* when nobody banned ever authored a Control edition — the overwhelmingly common +shape, since bans land on plain members who hold no role and write nothing. Those rows show no +regression. When a banned member *did* author editions — a banned staffer, exactly the case B2 exists +for — the fold costs ~2–3× more, and one more pass again in the rare case where a banned member had +themselves authored a ban. That is the price of the fix and it is paid only by communities under the +attack. + +**Worth knowing, unrelated to this work:** Amethyst re-folds the whole buffer from scratch on every +Control Plane change, and `resolve` runs once per held epoch inside `controlFloorsLocked` plus once +in `fold`, so a refresh is already several folds. Armada memoizes the fold by +`(community, owner, floors, snapshot, edition ids)`; we do not. That is the real optimization here, +it predates these fixes, and it would also absorb the pass-B cost. Left alone deliberately — it is a +change to make on its own merits, with its own measurements. + +--- + +## What was NOT examined + +This audit is bounded by what was opened. Checked and found sound: the wrap/seal envelope (no author +impersonation — `rumor.pubKey == seal.pubKey` and `rumor.verifyId()`), Concord chat edits +(`Note.latestConcordEdit` is author-gated, so a member cannot rewrite someone else's message), and +self-unban (B2). + +Not looked at at all: + +- **Private channels** (CORD-03 derived keys) — key delivery on grant, and channel-scoped rekey. + Note that no channel-scoped rekey *receive* path appears to exist: `drainConcordRekeys` handles + `ROOT_SCOPE` only, and `entry.privateChannels` is carried forward but never populated by a + delivery path. If that is right, the only removal Amethyst can perform is a full-community + Refounding — which is exactly what B4 makes expensive. +- **In-plane reactions and deletes** — the edit path is author-gated; the delete path was not read. +- **Guestbook kicks** (kind 3309) — the builder documents a KICK-bit + rank rule; the receive side + was not verified against it. +- Unread counts and notification triggers, media/upload references from messages, the NIP-53 nests + overlap, and the desktop client's Concord paths. + +## What is left + +1. **A2's liveness half** — decide whether a community re-mints its invite bundle at a stable + coordinate. Today nothing does, so stranded recovery never fires for anyone, and an owner evicted + by a rogue admin has no route back. Needs a spec answer before code. +2. **C1 / B2 interop** — tell Armada about the two-pass rule, as with the rank rule before it. The + divergence is now wider: we drop editions they honor whenever a privileged member is banned. +3. **B6** — a CORD-01 note that a plane's wraps must stay owned by a key nobody holds, plus guidance + that a relay authorizing NIP-09/62 by `pubkey` hands every ex-member a wipe button. +4. **B3's residue** — a legitimately privileged rotator can still omit an entity during compaction. + `EntityFloor` catches it for clients that were present; fresh joiners have nothing. +5. **A5** — a design pass on how "Ban" and "Remove from community" are presented, since they promise + very different things. +6. **The unexamined surfaces below**, particularly private channels — there appears to be no + channel-scoped rekey receive path at all, which would mean the full-community Refounding is the + only removal Amethyst can perform. diff --git a/geode/src/test/kotlin/com/vitorpamplona/geode/ConcordPlaneKeyDeletionTest.kt b/geode/src/test/kotlin/com/vitorpamplona/geode/ConcordPlaneKeyDeletionTest.kt new file mode 100644 index 0000000000..15c5fcd7a1 --- /dev/null +++ b/geode/src/test/kotlin/com/vitorpamplona/geode/ConcordPlaneKeyDeletionTest.kt @@ -0,0 +1,242 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.geode + +import com.vitorpamplona.quartz.concord.cord03Channels.ChannelChat +import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChannelKeys +import com.vitorpamplona.quartz.concord.envelope.ConcordStreamEnvelope +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.publishAndConfirm +import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync +import com.vitorpamplona.quartz.nip09Deletions.DeletionEvent +import com.vitorpamplona.quartz.nip62RequestToVanish.RequestToVanishEvent +import com.vitorpamplona.quartz.utils.RandomInstance +import com.vitorpamplona.quartz.utils.TimeUtils +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals + +/** + * Why a soft-banned member cannot delete a Concord community's history from the relay — and what + * keeps it that way. + * + * The worry is real. CORD-01 inverts NIP-59: every wrap on a plane is signed by the *shared stream + * key*, not by its author, and the true author only exists inside the encrypted seal. A member + * banned yesterday still derives `group_key("concord/channel", community_root, channel_id, epoch)` + * from the root they kept, so they can still sign events *as the channel itself*. If NIP-09 and + * NIP-62 authorized on the outer `pubkey`, [Nip09DeletionTest]'s "a kind-5 from pubkey X cannot + * delete pubkey Y's events" would be vacuous inside a plane: one event from any ex-member would + * erase the whole community's history. + * + * What stops it is [com.vitorpamplona.quartz.nip01Core.store.owner]: a kind-1059 gift wrap is + * controlled by its **p-tag recipient**, not its signer. Concord stamps a *freshly random* p-tag on + * every wrap ([ConcordStreamEnvelope.wrapSeal]), so each wrap is owned by a one-time key that + * nobody — attacker, author, or owner — ever holds. The channel is undeletable by construction. + * + * Both halves of that are load-bearing and neither was written for this reason, so both are pinned + * here: [theEphemeralPTagIsWhatMakesTheChannelUndeletable] fails the moment the p-tag becomes a + * real key, and the first two tests fail the moment ownership goes back to the signer. + * + * **Scope.** This is our relay's rule, not the protocol's. A community publishes wherever its + * metadata points, and a third-party relay that reads NIP-09 the naive way — deletion authorized by + * matching `pubkey` — hands every ex-member a wipe button for the whole channel. The protocol-level + * fix is the same one that already exists for everything else: a CORD-06 Refounding rotates the + * plane address, which protects the future but cannot restore what a relay already dropped. + */ +class ConcordPlaneKeyDeletionTest { + private lateinit var hub: InProcessRelays + private lateinit var scope: CoroutineScope + private lateinit var client: NostrClient + private val relayUrl: NormalizedRelayUrl = RelayUrlNormalizer.normalize("ws://127.0.0.1:7770/") + + @BeforeTest + fun setup() { + hub = InProcessRelays() + scope = CoroutineScope(Dispatchers.Default + SupervisorJob()) + client = NostrClient(hub, scope) + } + + @AfterTest + fun teardown() { + client.disconnect() + scope.cancel() + hub.close() + } + + private suspend fun query(filter: Filter): List { + val ch = Channel(Channel.UNLIMITED) + val subId = "sub-${System.nanoTime()}" + client.subscribe( + subId, + mapOf(relayUrl to listOf(filter)), + object : SubscriptionListener { + override suspend fun onEvent( + event: Event, + isLive: Boolean, + relay: NormalizedRelayUrl, + forFilters: List?, + ) { + ch.trySend(Msg.Ev(event)) + } + + override fun onEose( + relay: NormalizedRelayUrl, + forFilters: List?, + ) { + ch.trySend(Msg.Eose) + } + }, + ) + val events = mutableListOf() + withTimeout(5000) { + while (true) { + when (val msg = ch.receive()) { + is Msg.Ev -> events += msg.event + Msg.Eose -> return@withTimeout + } + } + } + client.unsubscribe(subId) + return events + } + + private sealed interface Msg { + data class Ev( + val event: Event, + ) : Msg + + object Eose : Msg + } + + /** A public channel plane: derived from the community root, so every member holds its secret. */ + private val communityRoot = RandomInstance.bytes(32) + private val channelId = RandomInstance.bytes(32) + private val plane = ConcordChannelKeys.publicChannel(communityRoot, channelId, rootEpoch = 0) + + /** The plane's own signer — what the banned member reconstructs from the root they kept. */ + private fun planeSigner() = NostrSignerSync(KeyPair(privKey = plane.secretKey)) + + private suspend fun postAs( + author: NostrSignerInternal, + text: String, + createdAt: Long, + ): Event { + val rumor = ChannelChat.message(author.pubKey, channelId.toHexKey(), epoch = 0, text = text, createdAt = createdAt) + return ConcordStreamEnvelope.wrap(rumor, plane, author, encrypted = true, createdAt = createdAt) + } + + @Test + fun aPlaneKeyHolderCannotDeleteTheChannelsHistory() = + runBlocking { + val now = TimeUtils.now() + val bob = NostrSignerInternal(KeyPair()) + val carol = NostrSignerInternal(KeyPair()) + + val history = + listOf( + postAs(bob, "hello", now), + postAs(carol, "hi bob", now + 1), + postAs(bob, "how's the project going?", now + 2), + ) + history.forEach { assertEquals(true, client.publishAndConfirm(it, setOf(relayUrl)), "seed the channel history") } + assertEquals(3, query(Filter(authors = listOf(plane.publicKeyHex))).size, "three messages on the plane") + + // The banned member still derives `plane`, and every wrap above IS authored by it — so + // this kind-5 satisfies a same-author check. It must still be refused. + val deletion = planeSigner().sign(DeletionEvent.build(history, createdAt = now + 10)) + assertEquals(true, client.publishAndConfirm(deletion, setOf(relayUrl)), "the relay accepts the event itself") + + assertEquals( + 3, + query(Filter(authors = listOf(plane.publicKeyHex), kinds = listOf(ConcordStreamEnvelope.KIND_WRAP))).size, + "signing as the plane must NOT delete the community's messages", + ) + } + + @Test + fun aPlaneKeyHolderCannotVanishTheChannelPlane() = + runBlocking { + val now = TimeUtils.now() + val bob = NostrSignerInternal(KeyPair()) + + val history = listOf(postAs(bob, "one", now), postAs(bob, "two", now + 1)) + history.forEach { client.publishAndConfirm(it, setOf(relayUrl)) } + assertEquals(2, query(Filter(authors = listOf(plane.publicKeyHex))).size) + + // NIP-62 needs no per-event targeting: one event, and everything that pubkey published + // is gone. The sharpest version of the attack, and the same rule has to stop it. + val vanish = planeSigner().sign(RequestToVanishEvent.build(relayUrl, "", createdAt = now + 10)) + assertEquals(true, client.publishAndConfirm(vanish, setOf(relayUrl))) + + assertEquals( + 2, + query(Filter(authors = listOf(plane.publicKeyHex), kinds = listOf(ConcordStreamEnvelope.KIND_WRAP))).size, + "a kind-62 signed as the plane must not wipe the channel", + ) + } + + @Test + fun theEphemeralPTagIsWhatMakesTheChannelUndeletableSoDoNotMakeItMeaningful() = + runBlocking { + // The counterfactual, so the invariant is visible rather than incidental: ownership of a + // 1059 follows the p-tag, so a wrap addressed to a REAL key is deletable by whoever holds + // that key. Concord is safe only because `wrapSeal` stamps a fresh throwaway pubkey there. + // If that p-tag ever becomes something a member holds — a recipient, a channel id, a + // community id — every ex-holder of it can delete the plane's history. + val now = TimeUtils.now() + val mallory = NostrSignerInternal(KeyPair()) + + val addressedWrap = + planeSigner().signNormal( + now, + ConcordStreamEnvelope.KIND_WRAP, + arrayOf(arrayOf("p", mallory.pubKey)), + "not-a-real-seal", + ) + assertEquals(true, client.publishAndConfirm(addressedWrap, setOf(relayUrl))) + assertEquals(1, query(Filter(ids = listOf(addressedWrap.id))).size) + + val deletion = mallory.sign(DeletionEvent.build(listOf(addressedWrap), createdAt = now + 1)) + assertEquals(true, client.publishAndConfirm(deletion, setOf(relayUrl))) + + assertEquals( + 0, + query(Filter(ids = listOf(addressedWrap.id))).size, + "the p-tag recipient owns a 1059 — which is exactly why Concord's p-tag must stay random", + ) + } +} diff --git a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletContentServer.kt b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletContentServer.kt index 7f32a39474..b7186df770 100644 --- a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletContentServer.kt +++ b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletContentServer.kt @@ -20,6 +20,7 @@ */ package com.vitorpamplona.amethyst.napplethost +import android.util.Base64 import android.webkit.WebResourceRequest import android.webkit.WebResourceResponse import com.vitorpamplona.amethyst.commons.napplet.NappletWebContract @@ -60,6 +61,9 @@ class NappletContentServer( // The host posture: a WEBSITE nSite is a normal web app — NIP-07 window.nostr provider, normal // network (no app CSP; off-origin requests defer to the WebView), unlike a locked NAPPLET. private val profile: HostProfile = HostProfile.NAPPLET, + // Exact NAP domains the trusted main process authorized for this launch. The injected prelude + // projects only these objects; absence is the NIP-5D capability-availability signal. + private val declaredDomains: List = emptyList(), // Embedded surfaces (a windowless Service) can't host the soft keyboard, so the shim installs the // IME proxy agent that relays the focused field to the host's keyboard. The full-screen Activity // host has a native keyboard and leaves this false. @@ -130,15 +134,44 @@ class NappletContentServer( } private fun serveShell(): WebResourceResponse { - // The shell HTML carries an APP_ORIGIN_PLACEHOLDER for the iframe src; bind it to this applet's - // origin so the shell frames exactly this applet (and the CSP frame-src is pinned to it too). - val html = shellHtmlBytes.decodeToString().replace(NappletWebContract.APP_ORIGIN_PLACEHOLDER, appOrigin).encodeToByteArray() + val sandbox: String + val frameSource: String + val bootstrap: String + + if (profile == HostProfile.NAPPLET) { + // NIP-5D identity is bound to the exact verified bytes that execute. Resolve the sole + // /index.html blob, inject host-owned policy/prelude outside its aggregate hash, then + // hand those bytes to the opaque-origin child via srcdoc (never a navigated src). + val resolution = resolveCacheFirst("/index.html") + if (resolution !is StaticSiteResolution.Resolved) return notFound() + val encoded = Base64.encodeToString(injectShim(resolution.bytes), Base64.NO_WRAP) + sandbox = "allow-scripts" + frameSource = "'self'" + bootstrap = + "var b=atob('$encoded'),u=new Uint8Array(b.length);" + + "for(var j=0;jwindow.__nappletNip07=true;" else "" - // Embedded surface: turn on the IME proxy agent (set before the shim runs). - val imeFlag = if (imeProxy) "" else "" - val script = "$style$nip07Flag$imeFlag" - val headIdx = text.indexOf("= 0 -> { - val close = text.indexOf('>', headIdx) - if (close >= 0) text.substring(0, close + 1) + script + text.substring(close + 1) else script + text - } - else -> script + text - } - return injected.encodeToByteArray() - } + /** Inserts host policy and the explicit-domain `window.napplet` prelude before authored code. */ + private fun injectShim(html: ByteArray): ByteArray = + NappletWebContract.injectPrelude( + html = html, + shimJs = shimJs, + declaredDomains = declaredDomains, + locked = profile == HostProfile.NAPPLET, + injectNip07 = profile.injectsNip07, + imeProxy = imeProxy, + ) private fun notFound(): WebResourceResponse = WebResourceResponse("text/plain", "utf-8", 404, "Not Found", emptyMap(), ByteArrayInputStream(ByteArray(0))) diff --git a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletHostActivity.kt b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletHostActivity.kt index d061653f72..74c3c6aaac 100644 --- a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletHostActivity.kt +++ b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletHostActivity.kt @@ -67,7 +67,6 @@ import androidx.webkit.WebViewCompat import androidx.webkit.WebViewFeature import com.vitorpamplona.amethyst.commons.napplet.NappletWebContract import com.vitorpamplona.amethyst.commons.napplet.protocol.NappletProtocolJson -import com.vitorpamplona.amethyst.commons.napplet.resolveRequiredCapabilities import com.vitorpamplona.amethyst.napplethost.R import com.vitorpamplona.quartz.nip01Core.core.toHexKey import com.vitorpamplona.quartz.nip5aStaticWebsites.resolver.StaticSiteResolution @@ -243,7 +242,7 @@ class NappletHostActivity : ComponentActivity() { // "Open web" for a site makes everything direct — both its blob fetches (here) and its live // web traffic (the WebView proxy, below). Tor (the default) routes both through the SOCKS port. val effectiveProxy = if (useTor) proxyPort else -1 - contentServer = NappletContentServer(paths, servers, effectiveProxy, cacheDir, shellHtml, shim, appOrigin, profile) + contentServer = NappletContentServer(paths, servers, effectiveProxy, cacheDir, shellHtml, shim, appOrigin, profile, declaredDomains) // Create + warm the WebView NOW so its (slow, first-in-process) Chromium init runs on the main // thread concurrently with the index probe below (which runs on IO) — instead of serially after @@ -473,10 +472,10 @@ class NappletHostActivity : ComponentActivity() { webViewProfile = intent.getStringExtra(NappletHostContract.EXTRA_WEBVIEW_PROFILE) val requires = intent.getStringArrayListExtra(NappletHostContract.EXTRA_REQUIRES) ?: emptyList() - val resolved = resolveRequiredCapabilities(requires) - // shell is always available; the rest are the declared domains advertised to the applet in the - // handshake. (The broker enforces the authoritative set from the launch token, not this list.) - declaredDomains = (listOf("shell") + resolved.capabilities.map { it.name.lowercase() }).distinct() + val resolved = profile.declaredCapabilities(requires) + // Domain-object presence is the NIP-5D availability signal. The broker still enforces the + // authoritative set minted into the launch token in the main process. + declaredDomains = resolved.map { it.name.lowercase() }.distinct() return author.isNotEmpty() && launchToken.isNotEmpty() } @@ -677,13 +676,6 @@ class NappletHostActivity : ComponentActivity() { // correlate on its id. The broker reads `type` to decode and to build the .result reply. val envelope = runCatching { JSONObject(raw) }.getOrNull() ?: return - // Shell handshake: the SDK posts `shell.ready` (no id) and answers shell.supports() locally - // from the `shell.init` environment we send back here. - if (envelope.optString("type") == "shell.ready") { - runCatching { replyProxy.postMessage(NappletProtocolJson.encodeShellInit(declaredDomains, declaredDomains)) } - return - } - // Unbind a keyboard action as soon as the applet drops it (the broker's Done reply carries no // actionId, so the binding is removed here from the envelope itself). if (envelope.optString("type") == "keys.unregisterAction") { diff --git a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletHostService.kt b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletHostService.kt index ceaf53b6e2..37dd512352 100644 --- a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletHostService.kt +++ b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletHostService.kt @@ -54,8 +54,6 @@ import androidx.webkit.WebMessageCompat import androidx.webkit.WebViewCompat import androidx.webkit.WebViewFeature import com.vitorpamplona.amethyst.commons.napplet.NappletWebContract -import com.vitorpamplona.amethyst.commons.napplet.protocol.NappletProtocolJson -import com.vitorpamplona.amethyst.commons.napplet.resolveRequiredCapabilities import com.vitorpamplona.quartz.nip01Core.core.toHexKey import com.vitorpamplona.quartz.nip5aStaticWebsites.tags.PathTag import com.vitorpamplona.quartz.utils.sha256.sha256 @@ -201,7 +199,8 @@ class NappletHostService : Service() { if (author.isEmpty() || launchToken.isEmpty()) return null val requires = data.getStringArrayList(NappletHostContract.EXTRA_REQUIRES) ?: emptyList() - val declaredDomains = (listOf("shell") + resolveRequiredCapabilities(requires).capabilities.map { it.name.lowercase() }).distinct() + val profile = HostProfile.fromName(data.getString(NappletHostContract.EXTRA_HOST_PROFILE)) + val declaredDomains = profile.declaredCapabilities(requires).map { it.name.lowercase() }.distinct() val tab = NappletTab( @@ -212,7 +211,7 @@ class NappletHostService : Service() { author = author, identifier = data.getString(NappletHostContract.EXTRA_IDENTIFIER).orEmpty(), launchToken = launchToken, - profile = HostProfile.fromName(data.getString(NappletHostContract.EXTRA_HOST_PROFILE)), + profile = profile, useTor = data.getBoolean(NappletHostContract.EXTRA_USE_TOR, true), proxyPort = data.getInt(NappletHostContract.EXTRA_PROXY_PORT, -1), bgColor = data.getInt(NappletHostContract.EXTRA_BG_COLOR, android.graphics.Color.WHITE), @@ -300,7 +299,19 @@ class NappletHostService : Service() { NappletWebViewProfile.apply(context, wv, tab.webViewProfile) val appOrigin = NappletWebContract.appOrigin(deriveAppId(tab.author, tab.identifier)) val effectiveProxy = if (tab.useTor) tab.proxyPort else -1 - tab.contentServer = NappletContentServer(tab.paths, tab.servers, effectiveProxy, cacheDir, shellHtml, shimJs, appOrigin, tab.profile, imeProxy = true) + tab.contentServer = + NappletContentServer( + tab.paths, + tab.servers, + effectiveProxy, + cacheDir, + shellHtml, + shimJs, + appOrigin, + tab.profile, + tab.declaredDomains, + imeProxy = true, + ) hardenWebView(wv, tab) // Theme the pre-load background so the shell/app loading shows Amethyst's background, not white. @@ -468,11 +479,6 @@ class NappletHostService : Service() { val raw = message.data ?: return val envelope = runCatching { JSONObject(raw) }.getOrNull() ?: return - if (envelope.optString("type") == "shell.ready") { - runCatching { replyProxy.postMessage(NappletProtocolJson.encodeShellInit(tab.declaredDomains, tab.declaredDomains)) } - return - } - // IME events aren't brokered — the main app hosts the keyboard. Relay the envelope to the client. if (envelope.optString("type").startsWith("ime.")) { val reply = diff --git a/quartz/build.gradle.kts b/quartz/build.gradle.kts index 8a93c5758d..4771e49055 100644 --- a/quartz/build.gradle.kts +++ b/quartz/build.gradle.kts @@ -424,6 +424,10 @@ val verifyKmpPurity by tasks.registering { "Thread.sleep" to "use kotlinx.coroutines.delay or platform-specific actual", "java.util.UUID" to "use kotlin.uuid.Uuid", "kotlin.jvm.Synchronized" to "use a KMP lock primitive", + // The bare call, not just the annotation: `synchronized(lock) {}` resolves + // from kotlin-stdlib-jvm with no import, so it compiles on Android/JVM and + // only fails at the iOS compile step. Catch it here instead. + "synchronized(" to "`synchronized` is JVM-only — use a KMP lock primitive", "kotlin.jvm.Volatile" to "use kotlin.concurrent.Volatile", ) val offenders = diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/AuthorityResolver.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/AuthorityResolver.kt index 039a63e7ca..5677e56244 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/AuthorityResolver.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/AuthorityResolver.kt @@ -21,6 +21,7 @@ package com.vitorpamplona.quartz.concord.cord04Roles import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.utils.Log /** * Resolves the owner-rooted authority state of a Concord community from its @@ -132,12 +133,101 @@ data class AuthorityResolver private constructor( } companion object { + private const val TAG = "ConcordAuthorityResolver" + /** The owner's rank — supreme and unremovable. No Role may claim it. */ const val OWNER_RANK = 0L + /** + * How many times [resolve] will re-fold chasing a stable banlist. Real communities settle on + * the first or second — the mask only moves when a banned member authored a *ban*, and it + * stops moving as soon as those are gone. The cap is a termination backstop for an + * adversarial edition set, not a tuning knob. + */ + private const val MAX_BAN_RESOLUTION_PASSES = 4 + + /** + * The owner-rooted authority state of a community, with the banlist honored **against the + * Control Plane itself** (CORD-04 §4: a reader "drops every event from a banned npub — + * message, reaction, edit, or authority action"). + * + * This is a bounded two-pass, because the rule is circular as stated: you cannot know who is + * banned until you fold the Banlist, and you cannot decide who may write the Banlist without + * knowing who is banned. `docs/concord-banlist-rank-conformance.md` §4 row 3 flagged that to + * the spec authors and left it open. We resolve it by making authority only ever **shrink**: + * + * - **Pass A** resolves exactly as before, ban-blind, and yields a candidate banlist. + * - **Pass B** re-resolves with every author in that banlist treated as unauthorized, for + * roles, grants and the banlist alike. + * + * Two passes, always, so it terminates by construction — pass B never feeds back. It cannot + * oscillate on mutual bans either, because the rank rule makes them unreachable: only a + * member who strictly outranks you may ban you, and you cannot outrank them back. + * + * **This cascades, deliberately.** Every edition a banned member ever authored is dropped, + * including grants they made while in good standing — so banning an admin also demotes + * everyone that admin promoted. That is the literal reading of §4, and it is the point: the + * escalation in `docs/concord-soft-ban-audit.md` B2 was a banned staffer minting a fresh, + * un-banned npub and acting through it, and dropping the grant is what kills the puppet. The + * cost is that a legitimate promotion by a later-banned admin vanishes too, and the owner has + * to re-issue it. + * + * **Consensus-affecting.** Armada gates the Control Plane on role-derived permissions alone, + * so until it ships the same rule the two clients can disagree about any community where a + * privileged member was banned. + */ fun resolve( editions: Collection, ownerPubKey: String, + ): AuthorityResolver { + val passA = resolveOnce(editions, ownerPubKey, bannedAuthors = emptySet()) + // A further pass costs a whole fold, so skip it unless it could change something. Nobody + // banned, or nobody banned who ever wrote to the Control Plane — the overwhelmingly common + // shape, since most bans land on plain members who hold no role and author no editions — + // and the next pass is provably identical to this one. Armada's fold checks the same. + if (passA.banned.isEmpty()) return passA + if (editions.none { it.author.lowercase() in passA.banned }) return passA + + // Iterate to a fixpoint where the mask a pass was resolved UNDER equals the banlist that + // pass produced. Stopping at two passes leaves those two disagreeing, and the disagreement + // is not cosmetic: a moderator whose only ban came from an admin the owner banned + // concurrently is released by pass 2 — correctly — but pass 2 dropped her editions too, + // because she was on pass 1's list. The fold then reports her as a moderator in good + // standing whose promotions have silently vanished, and it does so deterministically, so + // she never gets them back. + // + // The mask cannot simply be assumed to shrink: masking an author can strip a THIRD + // member's role, dropping their rank to "roleless", which lets a junior BAN holder who + // could not previously reach them ban them after all. So this is bounded rather than + // proven monotone, and it keeps the last pass it computed if it somehow does not settle — + // still strictly better than the two-pass answer, and it always terminates. + var mask = passA.banned + var result = passA + repeat(MAX_BAN_RESOLUTION_PASSES) { + result = resolveOnce(editions, ownerPubKey, bannedAuthors = mask) + if (result.banned == mask) return result + mask = result.banned + } + // Exhausted the cap without settling. The returned roster was folded under a mask that is + // no longer the banlist beside it, so this is reported rather than swallowed — the same + // reasoning as EditionFold.LOG_GAP: an unsettled fold is either an adversarial edition set + // or a rule of ours that does not converge, and both are things a reader wants to know. + Log.w(TAG) { + "Banlist resolution did not settle in $MAX_BAN_RESOLUTION_PASSES passes for owner $ownerPubKey " + + "(${editions.size} editions, ${result.banned.size} banned): keeping the last pass" + } + return result + } + + /** + * One resolution pass. [bannedAuthors] are treated as holding no authority at all — their + * role, grant and banlist editions are dropped rather than merely being unable to act on + * others. Empty on pass A; pass A's banlist on pass B. See [resolve]. + */ + private fun resolveOnce( + editions: Collection, + ownerPubKey: String, + bannedAuthors: Set, ): AuthorityResolver { val ownerLower = ownerPubKey.lowercase() @@ -191,6 +281,7 @@ data class AuthorityResolver private constructor( ): Boolean { val author = e.author.lowercase() if (author == ownerLower) return true + if (author in bannedAuthors) return false if (!holdsManageRoles(author)) return false val authorRank = rankOf(author) ?: return false val r = ConcordJson.decodeOrNull(e.content) ?: return false @@ -223,6 +314,7 @@ data class AuthorityResolver private constructor( fun grantGate(e: ControlEdition): Boolean { val granter = e.author.lowercase() if (granter == ownerLower) return true + if (granter in bannedAuthors) return false if (!holdsManageRoles(granter)) return false val granterRank = rankOf(granter) ?: return false val g = ConcordJson.decodeOrNull(e.content) ?: return false @@ -269,7 +361,10 @@ data class AuthorityResolver private constructor( // a concurrent ban is never lost, while an on-chain unban still takes effect. val allBanlist = editions.filter { it.entityKind == ControlEntityKind.BANLIST } - fun banGate(e: ControlEdition): Boolean = e.author.lowercase() == ownerLower || effectivePermissionsOf(e.author.lowercase()).has(ConcordPermissions.BAN) + fun banGate(e: ControlEdition): Boolean { + val author = e.author.lowercase() + return author == ownerLower || (author !in bannedAuthors && effectivePermissionsOf(author).has(ConcordPermissions.BAN)) + } val authorizedBanlist = allBanlist.filter(::banGate) // CORD-04 §3's rank rule binds "every action", and it names banning as its example ("an @@ -292,6 +387,7 @@ data class AuthorityResolver private constructor( // owner is never a valid target — not even for themselves. if (target == ownerLower) return false if (author == ownerLower) return true + if (author in bannedAuthors) return false if (!effectivePermissionsOf(author).has(ConcordPermissions.BAN)) return false val authorRank = rankOf(author) ?: return false val targetRank = rankOf(target) ?: Long.MAX_VALUE // no roles ⇒ lowest authority diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/EditionFold.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/EditionFold.kt index 4e5b8fa72c..956a0ec461 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/EditionFold.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/EditionFold.kt @@ -150,9 +150,63 @@ object EditionFold { floorVersion: Long, ): ControlEdition? = editions - .filter { it.version >= floorVersion } + .filter { it.version >= floorVersion && it.version - floorVersion <= MAX_COMPACTION_VERSION_JUMP } .minWithOrNull(compareByDescending { it.version }.thenBy { it.rumorId }) + /** + * How far above the floor the compaction arm will follow an edition in one step. + * + * The arm trades contiguity for cross-epoch tolerance, which made VERSION the only contest an + * edition had to win — so a single authorized edition at `version = Long.MAX_VALUE` used to + * become an entity's permanent head: it won the arm, `authorizedHeads` raised the floor to + * `Long.MAX_VALUE`, and from there no honest edition could ever exceed the floor again. Even a + * Refounding that dropped the poison did not help, because nothing was then offered at or above + * the floor and the fold fell back to [EntityFloor.known] — the poison itself. See B1 in + * `docs/concord-soft-ban-audit.md`. + * + * A compacted head is legitimately ahead of the floor by however many editions the entity gained + * while we were away — a chain's worth, not 2^63. This bound is deliberately far above any real + * community (a channel renamed a thousand times a day for three years stays under it) and far + * below the point where the version space can be exhausted. Anything beyond it is not a + * compaction we missed; it is someone reaching for the ceiling, and it is treated as a gap. + */ + const val MAX_COMPACTION_VERSION_JUMP = 1_000_000L + + /** + * The floor-anchored chain head among [editions], or null when nothing connects to [floor]. + * + * The same anchor-then-walk the main path uses, factored out so the compaction arm can try it + * first: the anchor is the floor's own edition (same version AND hash) or its immediate + * successor citing that hash, then the walk climbs while each `version + 1` cites the current + * head. Reports no gap — a null here means "fall back", not "refuse". + */ + private fun chainHead( + editions: List, + floor: EntityFloor, + ): ControlEdition? { + val byVersion = HashMap>() + for (e in editions) byVersion.getOrPut(e.version) { ArrayList() }.add(e) + + val lowest = byVersion.keys.filter { it >= floor.version }.minOrNull() ?: return null + val winner = byVersion[lowest]?.minByOrNull { it.rumorId } ?: return null + var head = + when (lowest) { + floor.version -> winner.takeIf { it.hashHex == floor.hashHex } + floor.version + 1 -> winner.takeIf { it.prevHash != null && it.prevHash.toHexKey() == floor.hashHex } + else -> null + } ?: return null + + while (true) { + val next = + byVersion[head.version + 1] + ?.filter { it.prevHash != null && it.prevHash.toHexKey() == head.hashHex } + ?.minByOrNull { it.rumorId } + ?: break + head = next + } + return head + } + /** * Groups mixed [editions] by entity id and folds each to its head, honoring the * per-entity anti-rollback [floors] (keyed by [ControlEdition.entityIdHex]). @@ -210,10 +264,16 @@ object EditionFold { // to the compacted head. Presence of the entity in the snapshot selects the ARM; // version selects the HEAD, over every edition we hold and not just the subset. if (floor != null && snapshot != null && editions.any { it.rumorId in snapshot }) { + // Chain first, bootstrap only as the fallback. The arm exists for the case where the + // offered head genuinely cannot be connected — but when it CAN be, the connected head is + // strictly better evidence than "highest number wins", and preferring it denies a stray + // high-version edition its free win in every ordinary fold. The bootstrap keeps the + // cross-epoch case working, now bounded by MAX_COMPACTION_VERSION_JUMP. + chainHead(editions, floor)?.let { return it } return bootstrapHead(editions, floor.version) ?: run { - // Nothing at or above the floor was served: the head we already accepted - // vanished from the offered set — withheld, so fail closed. + // Nothing admissible at or above the floor was served: the head we already + // accepted vanished from the offered set — withheld, so fail closed. onGap(editions[0].entityIdHex, floor.version, editions.maxOf { it.version }) floor.known } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/CommunityInvite.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/CommunityInvite.kt index d652c70143..632a7f9e03 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/CommunityInvite.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/CommunityInvite.kt @@ -52,7 +52,7 @@ class InviteChannel( * into a kind-33301 bundle (link invites) or a NIP-59 giftwrap (direct invites). */ @Serializable -class CommunityInvite( +data class CommunityInvite( @SerialName("community_id") val communityId: String, val owner: String, @SerialName("owner_salt") val ownerSalt: String, diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordInviteBundle.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordInviteBundle.kt index ee528efb5b..4f36ea409d 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordInviteBundle.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordInviteBundle.kt @@ -109,6 +109,22 @@ object ConcordInviteBundle { return signer.sign(ConcordInviteBundleEvent.build(content, createdAt)) } + /** + * Builds the kind-33301 revocation tombstone that retires the link owned by [linkSignerPrivKey] + * (CORD-05 §2). It re-posts the link's own coordinate with empty content and `vsk=9`, so the + * newest event there is a grave rather than keys and [classify] resolves the link + * [InviteBundleStatus.Revoked] for everyone who resolves it afterwards. + * + * Only the creator can do this: the coordinate is addressable and authored by the link signer, + * so retiring a link requires the `link_signer` secret — which lives in the creator's kind-13303 + * Invite List and nowhere else. Losing that secret makes a link permanently un-revokable, which + * is why the list is written before a link is ever handed out. + */ + fun buildRevocation( + linkSignerPrivKey: ByteArray, + createdAt: Long, + ): Event = NostrSignerSync(KeyPair(privKey = linkSignerPrivKey)).sign(ConcordInviteBundleEvent.buildRevocation(createdAt)) + /** Decrypts a kind-33301 bundle [event] with the link [token], or null if it isn't a valid bundle. */ fun parse( event: Event, diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordInviteList.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordInviteList.kt new file mode 100644 index 0000000000..f136aed3fd --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordInviteList.kt @@ -0,0 +1,275 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.concord.cord05Invites + +import com.vitorpamplona.quartz.concord.cord04Roles.ConcordJson +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.KSerializer +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.descriptors.elementNames +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonTransformingSerializer +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject + +private val NoExtras: JsonObject = JsonObject(emptyMap()) + +/** + * One minted invite link, as the creator's own bookkeeping (CORD-05, kind 13303). + * + * [token] is both the link's unlock secret and the **merge key** across devices, and [signerSk] is + * the link signer's private key — which is what makes a link *refreshable*. The kind-33301 bundle is + * addressable and authored by that signer, so re-posting under it moves the link to the current + * epoch without changing the URL anyone already holds. Lose the secret and the link is orphaned at a + * dead epoch forever, which is what made stranded recovery unreachable in practice. + * + * [residue] carries wire keys this build does not model. Armada types both the entry and the + * tombstone as `[k: string]: unknown`, so unknown keys are part of the contract: dropping them on a + * re-encode deletes another client's data. + */ +class ConcordInviteListEntry( + val token: String, + val signerSk: String, + val communityId: String, + val url: String, + val label: String? = null, + val createdAt: Long = 0, + val expiresAt: Long? = null, + val residue: JsonObject = NoExtras, +) { + /** True when this link can no longer be joined, so it must not be refreshed (CORD-05). */ + fun isExpired(nowSecs: Long): Boolean = expiresAt != null && expiresAt <= nowSecs + + /** + * The link signer's pubkey — the addressable coordinate the bundle lives at, derived from the + * secret we kept. Refreshing or retiring a link means writing at exactly this author. + */ + fun signerPubKeyHex(): HexKey = KeyPair(privKey = signerSk.hexToByteArray()).pubKey.toHexKey() +} + +/** A retired link: the creator's record that [token] is gone, kept so a merge cannot resurrect it. */ +class ConcordInviteListTombstone( + val token: String, + val communityId: String, + val residue: JsonObject = NoExtras, +) + +/** + * The decoded kind-13303 document: live [entries], [tombstones], and document-level [residue]. + * + * [opaqueEntries] holds entries that did not type-check — a wrong-typed field from another client or + * a newer schema. They are carried verbatim rather than dropped (re-encoding without them would + * delete somebody's `signer_sk`) and rather than failing the whole read (which would refuse every + * future mint and revoke for this account until someone else repaired the list). + */ +class ConcordInviteListDocument( + val entries: List = emptyList(), + val tombstones: List = emptyList(), + val residue: JsonObject = NoExtras, + val opaqueEntries: List = emptyList(), +) { + companion object { + val EMPTY = ConcordInviteListDocument() + } +} + +/** + * Codec + merge for the CORD-05 Invite List (kind 13303), the creator's private, NIP-44 self- + * encrypted bookkeeping of the links they minted. Wire-compatible with Armada's `invite.ts`: + * + * ```jsonc + * { "entries": [ { "token", "signer_sk", "community_id", "url", "label?", "created_at", "expires_at?" } ], + * "tombstones": [ { "token", "community_id" } ] } + * ``` + */ +object ConcordInviteList { + private const val EXTRAS = "__extras" + + /** Wraps a generated serializer so unknown keys survive a decode → modify → encode. */ + private open class ExtrasPreserving( + delegate: KSerializer, + ) : JsonTransformingSerializer(delegate) { + @OptIn(ExperimentalSerializationApi::class) + private val known = delegate.descriptor.elementNames.toSet() - EXTRAS + + override fun transformDeserialize(element: JsonElement): JsonElement { + val obj = element as? JsonObject ?: return element + val extras = obj.filterKeys { it !in known } + if (extras.isEmpty()) return obj + return JsonObject(obj.filterKeys { it in known } + (EXTRAS to JsonObject(extras))) + } + + override fun transformSerialize(element: JsonElement): JsonElement { + val obj = element as? JsonObject ?: return element + val extras = obj[EXTRAS]?.jsonObject ?: return obj + return JsonObject(extras + (obj - EXTRAS)) + } + } + + @Serializable + private class WireEntry( + val token: String = "", + @SerialName("signer_sk") val signerSk: String = "", + @SerialName("community_id") val communityId: String = "", + val url: String = "", + val label: String? = null, + @SerialName("created_at") val createdAt: Long = 0, + @SerialName("expires_at") val expiresAt: Long? = null, + @SerialName(EXTRAS) val extras: JsonObject = NoExtras, + ) + + @Serializable + private class WireTombstone( + val token: String = "", + @SerialName("community_id") val communityId: String = "", + @SerialName(EXTRAS) val extras: JsonObject = NoExtras, + ) + + private object WireEntrySerializer : ExtrasPreserving(WireEntry.serializer()) + + private object WireTombstoneSerializer : ExtrasPreserving(WireTombstone.serializer()) + + @Serializable + private class WireDocument( + val entries: List< + @Serializable(WireEntrySerializer::class) + WireEntry, + > = emptyList(), + val tombstones: List< + @Serializable(WireTombstoneSerializer::class) + WireTombstone, + > = emptyList(), + @SerialName(EXTRAS) val extras: JsonObject = NoExtras, + ) + + private object WireDocumentSerializer : ExtrasPreserving(WireDocument.serializer()) + + /** + * Decodes the plaintext document, or **null** when the document itself cannot be read. + * + * Null rather than an empty document on purpose: this list is replaceable, so a caller that + * treats "I could not read it" as "it is empty" and republishes destroys every `signer_sk` it + * did not manage to read — secrets that cannot be regenerated, orphaning every outstanding + * invite at a dead epoch. Callers MUST distinguish the two (see [ConcordInviteList.merge]'s + * callers). + * + * Null is reserved for a *document-level* failure — not JSON, or `entries`/`tombstones` present + * but not arrays. A single entry that does not type-check is kept verbatim in + * [ConcordInviteListDocument.opaqueEntries] instead: failing the whole read for one odd row + * would refuse every future mint and revoke for the account, permanently, since a replaceable + * coordinate never ages out — turning the old silent data loss into a permanent write lock. + */ + fun decodeOrNull(json: String): ConcordInviteListDocument? = + try { + val root = ConcordJson.instance.parseToJsonElement(json).jsonObject + val opaque = mutableListOf() + + val entries = + (root["entries"]?.jsonArray ?: JsonArray(emptyList())).mapNotNull { element -> + val obj = element.jsonObject + try { + val it = ConcordJson.instance.decodeFromJsonElement(WireEntrySerializer, obj) + ConcordInviteListEntry(it.token, it.signerSk, it.communityId, it.url, it.label, it.createdAt, it.expiresAt, it.extras) + } catch (_: Exception) { + opaque.add(obj) + null + } + } + + val tombstones = + (root["tombstones"]?.jsonArray ?: JsonArray(emptyList())).mapNotNull { element -> + try { + val it = ConcordJson.instance.decodeFromJsonElement(WireTombstoneSerializer, element.jsonObject) + ConcordInviteListTombstone(it.token, it.communityId, it.extras) + } catch (_: Exception) { + // A tombstone we cannot read must not silently un-retire its link, but we + // have no token to key it by, so it can only ride along as document residue. + null + } + } + + ConcordInviteListDocument( + entries = entries, + tombstones = tombstones, + residue = JsonObject(root - "entries" - "tombstones"), + opaqueEntries = opaque, + ) + } catch (_: Exception) { + null + } + + fun encode(doc: ConcordInviteListDocument): String { + val wire = + ConcordJson.instance + .encodeToJsonElement( + WireDocumentSerializer, + WireDocument( + entries = + doc.entries.map { + WireEntry(it.token, it.signerSk, it.communityId, it.url, it.label, it.createdAt, it.expiresAt, it.residue) + }, + tombstones = doc.tombstones.map { WireTombstone(it.token, it.communityId, it.residue) }, + extras = doc.residue, + ), + ).jsonObject + + // Entries we could not type ride back out untouched. Dropping them here is the data loss + // this whole class exists to prevent — they are somebody's link signer too. + if (doc.opaqueEntries.isEmpty()) return ConcordJson.instance.encodeToString(JsonObject.serializer(), wire) + val entries = JsonArray((wire["entries"]?.jsonArray ?: JsonArray(emptyList())) + doc.opaqueEntries) + return ConcordJson.instance.encodeToString(JsonObject.serializer(), JsonObject(wire + ("entries" to entries))) + } + + /** + * Merges [patch] onto [base], keyed by `token` — the spec's own merge key. A token present in + * either side's tombstones is dropped from the result and kept tombstoned, so a retired link + * cannot be resurrected by a device that still has it cached. [patch] wins field-by-field on a + * token both sides carry, which is what makes "read remote, apply my change, publish" converge. + */ + fun merge( + base: ConcordInviteListDocument, + patch: ConcordInviteListDocument, + ): ConcordInviteListDocument { + val tombstones = LinkedHashMap() + for (t in base.tombstones + patch.tombstones) tombstones[t.token] = t + + val entries = LinkedHashMap() + for (e in base.entries + patch.entries) { + if (e.token in tombstones) continue + entries[e.token] = e + } + return ConcordInviteListDocument( + entries = entries.values.toList(), + tombstones = tombstones.values.toList(), + residue = JsonObject(base.residue + patch.residue), + // Untyped entries survive the merge for the same reason they survive a decode: we cannot + // read them, so we are in no position to decide they are disposable. + opaqueEntries = (base.opaqueEntries + patch.opaqueEntries).distinct(), + ) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordInviteListEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordInviteListEvent.kt new file mode 100644 index 0000000000..3816f60a6f --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordInviteListEvent.kt @@ -0,0 +1,84 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.concord.cord05Invites + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.core.Address +import com.vitorpamplona.quartz.nip01Core.core.BaseReplaceableEvent +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.utils.TimeUtils + +/** + * The CORD-05 **Invite List** (kind 13303): the creator's private, NIP-44 self-encrypted record of + * every link they minted — `token` (the unlock secret and merge key) and `signer_sk` (the link + * signer's private key) per entry. + * + * It exists so a link can be *refreshed*: the kind-33301 bundle is addressable and authored by the + * link signer, so re-posting under it moves the link to the current epoch behind the same URL (e.g. + * after a Rekey). Without the list a client cannot re-sign at that coordinate, every rotation + * orphans every outstanding link, and stranded recovery — whose whole premise is re-resolving the + * link you joined through — can never fire. + * + * Replaceable and per-creator: the coordinate is (kind, creator pubkey, ""), so a creator's devices + * converge on one list. Merge by `token` ([ConcordInviteList.merge]) rather than overwriting, or two + * devices minting concurrently lose each other's links. + */ +@Immutable +class ConcordInviteListEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : BaseReplaceableEvent(id, pubKey, createdAt, KIND, tags, content, sig) { + /** + * Decrypts the whole document with [signer] — entries, tombstones and the document residue — or + * **null** if it cannot be decrypted or parsed. + * + * Null, never empty: a caller that reads a decrypt failure as "no links yet" and republishes + * wipes every `signer_sk` on this replaceable coordinate. A bunker signer that momentarily + * refuses is enough to trigger it. Use this (never a partial read) whenever the result will be + * re-encoded, or another client's unknown keys are dropped on the next publish. + */ + suspend fun decrypt(signer: NostrSigner): ConcordInviteListDocument? = + try { + ConcordInviteList.decodeOrNull(signer.nip44Decrypt(content, signer.pubKey)) + } catch (_: Exception) { + null + } + + companion object { + const val KIND = 13303 + + fun createAddress(pubKey: HexKey) = Address(KIND, pubKey, "") + + suspend fun create( + signer: NostrSigner, + document: ConcordInviteListDocument, + createdAt: Long = TimeUtils.now(), + ): ConcordInviteListEvent { + val content = signer.nip44Encrypt(ConcordInviteList.encode(document), signer.pubKey) + return signer.sign(createdAt, KIND, emptyArray(), content) + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordStrandedRecovery.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordStrandedRecovery.kt index 4f6e02d447..9869cadb91 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordStrandedRecovery.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordStrandedRecovery.kt @@ -46,12 +46,24 @@ object ConcordStrandedRecovery { * True when [bundle], resolved at [entry]'s stored invite link, proves we were * left behind: it must describe the same community and sit at a strictly higher * epoch. Same or lower is a no-op (we are current, or the bundle is stale). + * + * [bannedAtCurrentEpoch] is the caller's answer to "does the community, as I fold + * it right now, have me on its banlist?" — and a `true` refuses the recovery + * outright. It is a required argument rather than a caller-side `if` because + * getting it wrong turns this mechanism inside out: recovery exists so a member + * *wrongly* omitted from a rotation can catch up, but the test it performs (a + * higher epoch at a link whose unlock token an ex-member keeps forever) cannot + * tell that member apart from one the community deliberately removed. Without + * this, a Refounding — the only hard removal Concord has — is undone by our own + * background sweep a few minutes later. */ fun isStranded( entry: ConcordCommunityListEntry, bundle: CommunityInvite, + bannedAtCurrentEpoch: Boolean, ): Boolean = - entry.inviteRef != null && + !bannedAtCurrentEpoch && + entry.inviteRef != null && bundle.communityId.equals(entry.id, ignoreCase = true) && bundle.rootEpoch > entry.rootEpoch @@ -73,8 +85,9 @@ object ConcordStrandedRecovery { fun mergeForward( entry: ConcordCommunityListEntry, bundle: CommunityInvite, + bannedAtCurrentEpoch: Boolean, ): ConcordCommunityListEntry? { - if (!isStranded(entry, bundle)) return null + if (!isStranded(entry, bundle, bannedAtCurrentEpoch)) return null // Bank the epoch we are leaving with its control_pk, so its Control Plane // stays re-subscribable for the anti-rollback floor (a split epoch's address diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/bundle/ConcordInviteBundleEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/bundle/ConcordInviteBundleEvent.kt index 8e17c755fb..b2302cdf68 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/bundle/ConcordInviteBundleEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/bundle/ConcordInviteBundleEvent.kt @@ -68,5 +68,22 @@ class ConcordInviteBundleEvent( addUnique(VskTag.assemble(ControlEntityKind.INVITE_LIVE)) initializer() } + + /** + * Builds the revocation tombstone that retires a link: the **same** `["d",""]` coordinate, + * empty content, and `["vsk","9"]` ([ControlEntityKind.INVITE_REVOKED]). + * + * Empty content is the interop contract, not an omission — the spec's "a fetcher finds the + * grave instead of keys", and byte-for-byte what Armada's `buildRevocationEvent` emits. + * There is nothing to encrypt: the point is that no bundle key opens anything here. + */ + fun buildRevocation( + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate(KIND, "", createdAt) { + dTag("") + addUnique(VskTag.assemble(ControlEntityKind.INVITE_REVOKED)) + initializer() + } } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord06Rekey/ConcordRefounding.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord06Rekey/ConcordRefounding.kt index 18df7e3c46..74fb73b594 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord06Rekey/ConcordRefounding.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord06Rekey/ConcordRefounding.kt @@ -20,6 +20,7 @@ */ package com.vitorpamplona.quartz.concord.cord06Rekey +import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityState import com.vitorpamplona.quartz.concord.cord04Roles.ControlEdition import com.vitorpamplona.quartz.concord.crypto.ConcordKeyDerivation import com.vitorpamplona.quartz.concord.crypto.ControlPlaneKeys @@ -123,11 +124,12 @@ object ConcordRefounding { recipientsXOnly: List, staffXOnly: Set, createdAt: Long, + ownerPubKey: HexKey, ): RefoundingBuild { val newEpoch = rootEpoch + 1 val newControlKeys = ControlPlaneKeys.forStaff(newRoot, communityId, newEpoch, newControlRoot) - val controlWraps = compactControlPlane(priorControlWraps, priorControlKeys, newControlKeys) + val controlWraps = compactControlPlane(priorControlWraps, priorControlKeys, newControlKeys, ownerPubKey) val baseRekeyKey = ConcordKeyDerivation.baseRekeyAddress(priorRoot, communityId, newEpoch) val prevCommit = ConcordKeyDerivation.epochKeyCommitment(rootEpoch, priorRoot).toHexKey() @@ -166,19 +168,41 @@ object ConcordRefounding { priorWraps: List, priorControlKeys: ControlPlaneKeys, newControlKeys: ControlPlaneKeys, + ownerPubKey: HexKey, ): List { - // entity coordinate -> (head edition, its verified seal) - val heads = HashMap>() + // entity coordinate -> every edition we can open, paired with its verified seal. + val byCoordinate = HashMap>>() for (wrap in priorWraps) { val opened = ConcordStreamEnvelope.openOrNull(wrap, priorControlKeys) ?: continue val edition = ControlEdition.fromRumor(opened.rumor) ?: continue val coord = edition.entityKind.wire + ":" + edition.entityIdHex - val current = heads[coord] - if (current == null || edition.version > current.first.version) { - heads[coord] = edition to opened.seal - } + byCoordinate.getOrPut(coord) { ArrayList() }.add(edition to opened.seal) } - return heads.values.map { (_, seal) -> ConcordStreamEnvelope.wrapSeal(seal, newControlKeys, createdAt = seal.createdAt) } + + // The head to carry forward is the one every READER honors — the authority-gated head — not + // the highest version and not the bare structural chain head. + // + // Raw highest version made an honest rotator the delivery mechanism for a disconnected stray: + // an edition minted at an arbitrary version never joins the chain, but it won that comparison + // and was re-wrapped into the new epoch as the entity's whole history (B1 in + // `docs/concord-soft-ban-audit.md`). The bare chain walk is *worse*, and this is the trap: + // with no floor it anchors at the lowest-version edition carrying no `prev`, and after a prior + // compaction the real head's `prev` dangles by design — so a forged `version = 1, prev = null` + // decoy outranks a genuine v50→v52 chain and, because nothing here checks signatures, becomes + // the entity's entire carried-forward state. A forged empty banlist would erase every ban. + // + // Gating on the owner-rooted roster is the only selection that cannot be gamed by an + // unprivileged author, and it is exactly what ConcordCommunityState.fold would seat, so the + // compacted epoch starts where the previous one left off. + val editions = byCoordinate.values.flatten() + val honored = ConcordCommunityState.authorizedHeads(editions.map { it.first }, ownerPubKey) + val out = ArrayList(honored.size) + for ((_, floor) in honored) { + val head = floor.known ?: continue + val seal = editions.firstOrNull { it.first.rumorId == head.rumorId }?.second ?: continue + out.add(ConcordStreamEnvelope.wrapSeal(seal, newControlKeys, createdAt = seal.createdAt)) + } + return out } /** diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NegentropyFanOutResult.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NegentropyFanOutResult.kt index 96392ace8a..a7cbb1678f 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NegentropyFanOutResult.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NegentropyFanOutResult.kt @@ -36,4 +36,11 @@ class NegentropyFanOutResult( val downloaded: Int, val windows: Int, val connections: Int, + /** + * Ids `wantId` declined, so no `REQ` was ever issued for them. `0` when no + * predicate was passed. Same accounting as + * [NegentropySyncResult.skipped]: apart from [downloaded], and never folded + * into [needCount], which stays the honest protocol diff. + */ + val skipped: Int = 0, ) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NostrClientFetchAllPagesExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NostrClientFetchAllPagesExt.kt index 203311e8e4..e5e98116f8 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NostrClientFetchAllPagesExt.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NostrClientFetchAllPagesExt.kt @@ -134,6 +134,28 @@ data class PagedFetchResult( * cap, so a larger value is clamped to the same page). Stepping past at least keeps * the download progressing to older events instead of stalling forever. * + * **Two guards keep that step from becoming a walk that never ends**, both learned + * from a relay in production rather than from reasoning: + * + * - **The cursor floors at zero.** `created_at` is an unsigned timestamp, so nothing + * can exist below epoch 0: a cursor that would step under it has reached the bottom + * of the time axis and the walk is [PagedFetchResult.End.DRAINED]. `until = 0` + * itself is still asked — it is a legal query, and the boundary re-fetch for events + * stamped at the epoch — it is only going *below* it that ends the walk. This also + * keeps a negative `until` off the wire, which relays disagree violently about: + * measured across five, one CLOSEs the subscription with a parse error, three + * answer a `NOTICE` and then never EOSE, and one drops the bound and serves its + * NEWEST events. + * - **A relay that ignores the cursor is [PagedFetchResult.End.UNPAGEABLE].** If a + * page delivered nothing and every event it received was NEWER than the `until` it + * asked for, the relay is not paging at all, and stepping one second lower just + * asks the same unanswered question again. That is exactly how the first guard's + * relay behaves — it treats `until <= 0` as no `until` — and without this the walk + * ran ~5.5 pages a second, 500 events fetched and discarded on each, EOSE on every + * one, for as long as the process lived. UNPAGEABLE is deliberate and conservative: + * it proves nothing about what the relay holds, so no coverage claim can be built + * on a page the relay never really answered. + * * A `search` ([Filter.search]) filter is the exception: NIP-50 results are ranked by * relevance, not `created_at`, so paging one by a `until` cursor is meaningless — it * would silently turn a top-N search into a time-walk, and never terminate against a @@ -259,6 +281,14 @@ suspend fun INostrClient.fetchAllPages( val boundary = until var received = 0 var delivered = 0 + + /** + * Events that came back NEWER than the `until` this page asked for — which an + * honest relay never sends. Counted because it is the only way to tell a relay + * that ignored the cursor apart from a boundary second too dense to page: both + * deliver nothing, and only one of them can be fixed by stepping past. + */ + var aboveBoundary = 0 var pageMinTs = Long.MAX_VALUE val idsAtPageMin = HashSet() @@ -289,6 +319,9 @@ suspend fun INostrClient.fetchAllPages( // early) or an unsafely published `idsAtPageMin`. try { received++ + // Before the dedup return, so it is counted for every event + // the page received, not just the ones that reach the match. + if (boundary != null && event.createdAt > boundary) aboveBoundary++ // Drop a boundary-second event we already delivered on an // earlier page (the inclusive re-fetch returns it again). if (boundary != null && event.createdAt == boundary && event.id in seenAtBoundary) return @@ -414,6 +447,35 @@ suspend fun INostrClient.fetchAllPages( // are resolved by stepping strictly past it. `boundary` is null only on // the first page, which has no dedup and so can't be all-duplicate. val step = boundary ?: break // first page, all-duplicate: impossible, and `end` stays UNPAGEABLE + + // The relay is not honouring `until`: every event it sent was NEWER than + // the cursor this page asked for. Stepping past cannot help — the next + // page repeats the same ask one second lower and gets the same answer, + // forever. Measured on a live relay (purplepag.es, which treats + // `until <= 0` as no `until` and answers with its newest page): ~5.5 + // pages a second, 500 events fetched and discarded on each, `until` + // marching one second further negative every time, an EOSE on every + // single page, for as long as the process ran. This is the ONE reading + // that ends it, and it is safely conservative — UNPAGEABLE proves + // nothing about what the relay holds, so no coverage claim is built on + // a page the relay never actually answered. + if (aboveBoundary == received) { + end = PagedFetchResult.End.UNPAGEABLE + break + } + + // Below the boundary there is nothing left to ask for: `created_at` is an + // unsigned timestamp, so no event can exist under epoch 0 and a cursor + // stepping past it has reached the bottom of the time axis. Ending here + // rather than sending `until = -1` also keeps a value off the wire that + // relays disagree violently about — measured across five: one CLOSEs the + // subscription with a parse error, three answer a NOTICE and then never + // EOSE (so every page burns a whole idle timeout), one drops the bound + // and serves its newest events. + if (step <= 0L) { + end = PagedFetchResult.End.DRAINED + break + } until = step - 1 seenAtBoundary = HashSet() continue @@ -432,6 +494,17 @@ suspend fun INostrClient.fetchAllPages( // termination both rely on `until` never increasing. Honest relays only // return events at-or-below `until`, so this is a no-op for them. val nextUntil = if (boundary != null) minOf(pageMinTs, boundary) else pageMinTs + + // The same floor as the step above, on the other way the cursor moves. It is + // reachable here too, and not only through a bug: `pageMinTs` is an event's + // own `created_at`, so one relay serving a negative timestamp is enough to + // put the cursor under zero. Clamping to 0 instead of stopping would not + // help — such an event never equals the boundary, so it dodges the dedup and + // comes back on every page, pinning the walk there for good. + if (nextUntil < 0L) { + end = PagedFetchResult.End.DRAINED + break + } if (boundary != null && nextUntil == boundary) { seenAtBoundary.addAll(idsAtPageMin) } else { diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NostrClientFetchAllWithHooksExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NostrClientFetchAllWithHooksExt.kt index 62d13ce5e9..f28864dca0 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NostrClientFetchAllWithHooksExt.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NostrClientFetchAllWithHooksExt.kt @@ -79,6 +79,14 @@ suspend fun INostrClient.fetchAllWithHooks( subscriptionId: String = newSubId(), pendingOnAuthRequired: Boolean = false, deadOut: MutableMap? = null, + /** + * Receives the terminal reason per relay ("eose", "closed:…", "cannot:…"), so a caller can + * tell "a relay served us and had nothing" from "nobody served us". An empty result alone + * cannot: both look like zero events, and treating the second as the first is how a + * read-merge-write on a replaceable event destroys the entries it failed to read. See + * [anyRelayServed]. + */ + doneOut: MutableMap? = null, onTimeout: ((stalled: Set, doneReasons: Map, collected: List>) -> Unit)? = null, /** * Hard wall-clock ceiling. The idle window alone is unbounded when a relay @@ -237,9 +245,22 @@ suspend fun INostrClient.fetchAllWithHooks( classifyDrainFailure(reason)?.let { out[relay] = it } } } + doneOut?.putAll(doneReasons) return collected } +/** The terminal reason recorded when a relay finished serving a subscription normally. */ +const val DONE_REASON_EOSE = "eose" + +/** + * True when at least one relay completed the fetch normally, i.e. answered and reached EOSE. + * + * Read against the map filled by `fetchAllWithHooks`'s `doneOut`. An empty event list means + * "nothing matched" only when this is true; otherwise it means "nobody told us", and a caller + * that overwrites a replaceable event on that basis deletes whatever it could not read. + */ +fun Map.anyRelayServed(): Boolean = values.any { it == DONE_REASON_EOSE } + /** * [fetchAllPagesFromPool] with a suspending per-event hook: paginates every relay * to completion (each on its own `until` cursor, up to [maxConcurrentRelays] at diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NostrClientNegentropyFanOutExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NostrClientNegentropyFanOutExt.kt index e941af4417..35583fe02c 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NostrClientNegentropyFanOutExt.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NostrClientNegentropyFanOutExt.kt @@ -84,6 +84,13 @@ suspend fun negentropySyncFanOut( fetchBatch: Int = 250, idleTimeoutMs: Long = 120_000L, reconcileConcurrency: Int = 2, + /** + * Same contract as [negentropySync]'s: consulted for every id the reconcile + * names, before the `REQ` that would fetch it. Declined ids are counted in + * [NegentropyFanOutResult.skipped]. Called from several reconciler + * coroutines at once, so it must be cheap and thread-safe. + */ + wantId: ((HexKey) -> Boolean)? = null, onProgress: ((needSoFar: Int, downloaded: Int) -> Unit)? = null, onEvent: suspend (Event) -> Unit, ): NegentropyFanOutResult { @@ -91,6 +98,7 @@ suspend fun negentropySyncFanOut( val need = AtomicInt(0) val have = AtomicInt(0) + val skipped = AtomicInt(0) val windows = AtomicInt(0) val used = AtomicInt(0) var downloaded = 0 @@ -155,6 +163,7 @@ suspend fun negentropySyncFanOut( need.addAndFetch(it) }, onHave = { have.addAndFetch(it) }, + gate = NeedGate(wantId) { skipped.addAndFetch(it) }, sendNeedBatch = { batch -> idBatches.send(batch) }, sendHaveBatch = if (localEntries.isEmpty() && localIndex == null) null else { _ -> }, ) @@ -192,6 +201,7 @@ suspend fun negentropySyncFanOut( downloaded = downloaded, windows = windows.load(), connections = used.load(), + skipped = skipped.load(), ) } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NostrClientNegentropySyncExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NostrClientNegentropySyncExt.kt index 51941e1856..570f56c147 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NostrClientNegentropySyncExt.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NostrClientNegentropySyncExt.kt @@ -77,6 +77,18 @@ class NegentropySyncResult( val downloaded: Int, val windows: Int, val peerCap: Long? = null, + /** + * Ids the reconcile named that `wantId` declined, so no `REQ` was ever + * issued for them. Always `0` when no predicate was passed. + * + * Reported apart from [downloaded] and from [needCount] on purpose: + * [needCount] stays the honest protocol diff (what the relay has that the + * local set lacks) whether or not the caller chose to fetch it, and a + * skipped id was never a download. Folding either way would leave a caller + * unable to tell "my predicate is doing nothing" from "my predicate is + * eating everything" — and both are silent. + */ + val skipped: Int = 0, ) /** @@ -151,6 +163,26 @@ class NegentropySyncResult( * `limitation.max_subscriptions`; e.g. strfry defaults to 20) and exceeding the * cap can wedge the connection, not just fail the extra REQ — size the two knobs * to fit the target relay. + * @param wantId optional gate consulted for every id the reconcile names, + * BEFORE the `REQ` that would download it. Return `false` and the id is dropped + * from the fetch queue and counted in [NegentropySyncResult.skipped]; the + * reconcile itself is untouched, so [NegentropySyncResult.needCount] still + * reports the true diff. Called from the reconciler coroutines (possibly + * several at once when `reconcileConcurrency > 1`), so it must be cheap and + * thread-safe — a membership test, not a query. + * + * The case this exists for: a caller whose store will refuse an id no matter + * how often it arrives. A mirror that keeps only the newest version of a + * replaceable event is offered every relay's older copy on every sync, and + * without a hook here the only place to decline is after the body is already + * on the wire. `onEvent` is too late to save the bytes. + * + * **It does not cover a window handed to [onUnreconcilableWindow].** That + * window is drained by the caller over `REQ`, and a `REQ` names no ids before + * it streams bodies, so declined events in such a window arrive anyway and are + * not counted in [NegentropySyncResult.skipped]. Rare — it takes a single + * second denser than the relay's cap — but a caller treating the gate as an + * absolute bound on what it can receive would be wrong. * @param onProgress optional `(needSoFar, downloaded)` ticks as work proceeds. * @param onEvent called once per distinct event, serially, from the single * delivery consumer coroutine (not the relay reader thread) — so it never overlaps @@ -170,10 +202,12 @@ suspend fun INostrClient.negentropySync( localIndex: NegentropyLocalIndex? = null, targetWindow: Int = 0, onUnreconcilableWindow: (suspend (Filter) -> Unit)? = null, + wantId: ((HexKey) -> Boolean)? = null, onProgress: ((needSoFar: Int, downloaded: Int) -> Unit)? = null, onEvent: suspend (Event) -> Unit, ): NegentropySyncResult { val need = AtomicInt(0) + val skipped = AtomicInt(0) val windows = AtomicInt(0) var downloaded = 0 var peerCap: Long? = null @@ -213,6 +247,8 @@ suspend fun INostrClient.negentropySync( // single consumer loop below so the user callback is never // invoked from two coroutines at once. onNeed = { need.addAndFetch(it) }, + onSkipped = { skipped.addAndFetch(it) }, + wantId = wantId, deliver = { events.send(it) }, ) } finally { @@ -241,6 +277,7 @@ suspend fun INostrClient.negentropySync( downloaded = downloaded, windows = windows.load(), peerCap = peerCap, + skipped = skipped.load(), ) } @@ -257,6 +294,7 @@ suspend fun INostrClient.negentropySync( localIndex: NegentropyLocalIndex? = null, targetWindow: Int = 0, onUnreconcilableWindow: (suspend (Filter) -> Unit)? = null, + wantId: ((HexKey) -> Boolean)? = null, onProgress: ((needSoFar: Int, downloaded: Int) -> Unit)? = null, onEvent: suspend (Event) -> Unit, ): NegentropySyncResult = @@ -273,6 +311,7 @@ suspend fun INostrClient.negentropySync( localIndex = localIndex, targetWindow = targetWindow, onUnreconcilableWindow = onUnreconcilableWindow, + wantId = wantId, onProgress = onProgress, onEvent = onEvent, ) @@ -342,6 +381,14 @@ suspend fun INostrClient.negentropySyncOrFetch( localEntries: List = emptyList(), localIndex: NegentropyLocalIndex? = null, targetWindow: Int = 0, + /** + * Passed straight to [negentropySync]. Note it does NOT apply to the paged + * fallback: a `REQ` names no ids before it streams bodies, so there is + * nothing to gate there. A caller relying on this to bound its downloads + * should read [NegentropyOrFetchResult.pagedFallback] and expect the + * suppressed ids to arrive after all when a window pages. + */ + wantId: ((HexKey) -> Boolean)? = null, onProgress: ((needSoFar: Int, downloaded: Int) -> Unit)? = null, onEvent: suspend (Event) -> Unit, ): NegentropyOrFetchResult { @@ -397,6 +444,7 @@ suspend fun INostrClient.negentropySyncOrFetch( if (accept(event)) onProgress?.invoke(delivered, delivered) } }, + wantId = wantId, onProgress = onProgress, ) { accept(it) } NegentropyOrFetchResult( @@ -440,6 +488,7 @@ suspend fun INostrClient.negentropySyncOrFetch( localEntries: List = emptyList(), localIndex: NegentropyLocalIndex? = null, targetWindow: Int = 0, + wantId: ((HexKey) -> Boolean)? = null, onProgress: ((needSoFar: Int, downloaded: Int) -> Unit)? = null, onEvent: suspend (Event) -> Unit, ): NegentropyOrFetchResult = @@ -455,10 +504,53 @@ suspend fun INostrClient.negentropySyncOrFetch( localEntries = localEntries, localIndex = localIndex, targetWindow = targetWindow, + wantId = wantId, onProgress = onProgress, onEvent = onEvent, ) +/** + * Decides which of the ids a reconcile named are actually worth a `REQ`. + * + * Small and separate because it is the only place in the download path where + * an id can still be declined for free, and because the three things it does + * are each easy to get wrong in a lambda nobody can call from a test: apply + * the predicate, count what was dropped, and refuse to emit an empty batch. + * + * [keep] runs on the reconciler coroutines, so with `reconcileConcurrency > 1` + * it is called concurrently — hence the counting goes through [onSkipped], + * which the caller makes atomic, rather than a field here. + * + * **It is applied to a whole reconcile round's ids, before they are chunked + * into fetch batches.** Gating after the chunking instead would keep the batch + * COUNT and shrink every one of them: at the density this exists for (a mirror + * declining most of what it is offered) a `fetchBatch` of 500 would become a + * hundred `REQ`s of five ids apiece, each with its own EOSE round trip — + * turning a bandwidth saving into a latency regression. + */ +internal class NeedGate( + private val wantId: ((HexKey) -> Boolean)?, + private val onSkipped: (Int) -> Unit, +) { + /** + * The subset of [batch] to download. Empty when everything was declined — + * deliberately NOT null: the caller's chunk loop already does nothing with + * an empty list, and a nullable return here invites + * `gate?.keep(ids) ?: ids`, which reads as "no gate, keep everything" and + * silently means "everything was declined, so send everything". That exact + * elvis collapsed the fully-declining case into a full download once. + * + * With no predicate this returns [batch] itself — the unfiltered sync is + * the common case and must not pay a copy for a feature it is not using. + */ + fun keep(batch: List): List { + val wanted = if (wantId == null) batch else batch.filter(wantId) + val dropped = batch.size - wanted.size + if (dropped > 0) onSkipped(dropped) + return wanted + } +} + /** * The whole-sync pipeline: a single pool of [maxConcurrentReqs] download workers * fed by up to [reconcileConcurrency] concurrent window reconciliations through a @@ -494,6 +586,8 @@ private suspend fun INostrClient.syncPipeline( targetWindow: Int, onWindow: () -> Unit, onNeed: (Int) -> Unit, + onSkipped: (Int) -> Unit, + wantId: ((HexKey) -> Boolean)?, onPeerCap: ((Long) -> Unit)?, onUnreconcilableWindow: (suspend (Filter) -> Unit)?, deliver: suspend (Event) -> Unit, @@ -526,6 +620,9 @@ private suspend fun INostrClient.syncPipeline( onHave = {}, onPeerCap = onPeerCap, onUnreconcilableWindow = onUnreconcilableWindow, + // The gate is applied inside the reconcile, before these batches are + // cut — see NeedGate — so by here every id is one we want. + gate = NeedGate(wantId, onSkipped), sendNeedBatch = { batch -> idBatches.send(batch) }, sendHaveBatch = null, ) @@ -583,6 +680,9 @@ internal suspend fun reconcileWindows( // that hit the window, so a slow drain holds that reconciler — with // reconcileConcurrency = 1 the rest of the sweep waits for it. onUnreconcilableWindow: (suspend (Filter) -> Unit)? = null, + // Applied to each round's need ids before they are chunked. Null for the + // callers that hand the ids straight to their own consumer. + gate: NeedGate? = null, sendNeedBatch: suspend (List) -> Unit, sendHaveBatch: (suspend (List) -> Unit)?, ) = coroutineScope { @@ -703,6 +803,7 @@ internal suspend fun reconcileWindows( fetchBatch = batchSize, onNeed = onNeed, onHave = onHave, + gate = gate, sendNeedBatch = sendNeedBatch, sendHaveBatch = sendHaveBatch, ) @@ -1032,6 +1133,7 @@ private suspend fun INostrClient.reconcileStreaming( fetchBatch: Int, onNeed: (Int) -> Unit, onHave: (Int) -> Unit, + gate: NeedGate? = null, sendNeedBatch: suspend (List) -> Unit, sendHaveBatch: (suspend (List) -> Unit)?, ): ReconcileOutcome { @@ -1170,13 +1272,19 @@ private suspend fun INostrClient.reconcileStreaming( val result = session.processMessage(frame.payload) val needIds = result.needIds if (needIds.isNotEmpty()) { + // Counted before the gate: this is the protocol diff, and + // it is true whether or not the caller wants to fetch it. onNeed(needIds.size) + // Gated before the chunking, so a selective predicate + // yields FEWER full batches rather than the same number + // of nearly-empty ones — see NeedGate. + val wanted = if (gate == null) needIds else gate.keep(needIds) var i = 0 - while (i < needIds.size) { - val end = min(i + fetchBatch, needIds.size) + while (i < wanted.size) { + val end = min(i + fetchBatch, wanted.size) // Copy each batch so the frame's full id list can be freed // as soon as it is chunked; suspends under back-pressure. - sendNeedBatch(ArrayList(needIds.subList(i, end))) + sendNeedBatch(ArrayList(wanted.subList(i, end))) i = end } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt index 622fa0b889..3955952fdd 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt @@ -101,6 +101,7 @@ import com.vitorpamplona.quartz.buzz.wpWorkspaceProfile.SetWorkspaceProfileEvent import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEvent import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChatEditEvent import com.vitorpamplona.quartz.concord.cord04Roles.control.ControlEditionEvent +import com.vitorpamplona.quartz.concord.cord05Invites.ConcordInviteListEvent import com.vitorpamplona.quartz.concord.cord05Invites.bundle.ConcordInviteBundleEvent import com.vitorpamplona.quartz.experimental.agora.FundraiserEvent import com.vitorpamplona.quartz.experimental.attestations.attestation.AttestationEvent @@ -804,6 +805,7 @@ class EventFactory { RequestToVanishEvent.KIND -> RequestToVanishEvent(id, pubKey, createdAt, tags, content, sig) ConcordCommunityListEvent.KIND -> ConcordCommunityListEvent(id, pubKey, createdAt, tags, content, sig) ControlEditionEvent.KIND -> ControlEditionEvent(id, pubKey, createdAt, tags, content, sig) + ConcordInviteListEvent.KIND -> ConcordInviteListEvent(id, pubKey, createdAt, tags, content, sig) ConcordInviteBundleEvent.KIND -> ConcordInviteBundleEvent(id, pubKey, createdAt, tags, content, sig) SealedRumorEvent.KIND -> SealedRumorEvent(id, pubKey, createdAt, tags, content, sig) SearchRelayListEvent.KIND -> SearchRelayListEvent(id, pubKey, createdAt, tags, content, sig) diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/BannedStaffEscalationTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/BannedStaffEscalationTest.kt new file mode 100644 index 0000000000..3bed1c8575 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/BannedStaffEscalationTest.kt @@ -0,0 +1,397 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.concord.cord04Roles + +import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityState +import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +/** + * **B2 in `docs/concord-soft-ban-audit.md` — regression guard.** What a soft-banned staffer used to + * be able to do to a community, and can no longer. Every test here failed before the two-pass rule + * in [AuthorityResolver.resolve] and passes after it. + * + * The asymmetry they were written to pin: [ConcordCommunityState.fold] gates METADATA / CHANNEL / + * INVITE through `authority.hasPermission`, which is `!isBanned && …`, but ROLE, GRANT and BANLIST + * were gated *inside* [AuthorityResolver.resolve] by `holdsManageRoles` / `bitsOf` / + * `effectivePermissionsOf` — none of which consulted the banlist, nor could they as written, since + * the roles/grants fixpoint ran before `banned` was computed at all. Half the Control Plane honored + * a ban and half was structurally blind to it, so a banned member still holding `control_root` kept + * full authority over the roster: they banned everyone beneath them, revoked the surviving + * moderators, retired the roles under them, and minted a fresh un-banned npub that passed every + * ban-aware gate and finished the job. + * + * The fix resolves the ordering by making authority only ever shrink across two passes — see + * [AuthorityResolver.resolve]. Note that a chain-local rule ("the author must not be banned by the + * state their edition chains from") would NOT have been enough: + * [aBannedAdminForksTheBanlistAtGenesisRatherThanChainingOntoTheirOwnBan] forks at genesis so no + * parent ever mentions the ban, and CORD-04 §4's re-heal union would carry it in anyway. The rule + * had to bind the union too, which is why it is expressed as a whole-pass mask. + * + * Three tests pin behaviour the fix had to *preserve* rather than change: + * [selfUnbanIsStillRefused], [aJuniorPuppetCannotLiftASeniorsBan], and + * [aBanByOneAdminDoesNotDropTheGrantsOfAnother]. One pins the cost it deliberately accepts: + * [banningAnAdminAlsoDemotesEveryoneThatAdminPromoted]. + */ +class BannedStaffEscalationTest { + private val owner = "0f".repeat(32) + private val alice = "a1".repeat(32) // Admin, position 1 — the member who gets banned + private val bob = "b2".repeat(32) // Mod, position 5 + private val carol = "c3".repeat(32) // plain member, no role + private val puppet = "e5".repeat(32) // a fresh npub alice controls + + private val adminRole = "11".repeat(32) + private val modRole = "22".repeat(32) + private val puppetRole = "33".repeat(32) + + private val banlistEntity = "44".repeat(32) + private val channelEntity = "55".repeat(32) + private val metadataEntity = "66".repeat(32) + private val bobGrantEntity = "32".repeat(32) + private val puppetGrantEntity = "35".repeat(32) + + // MANAGE_ROLES|MANAGE_CHANNELS|MANAGE_METADATA|KICK|BAN|CREATE_INVITE = 1+2+4+8+16+64 + private val adminJson = """{"name":"Admin","position":1,"permissions":"95"}""" + private val modJson = """{"name":"Mod","position":5,"permissions":"24"}""" // KICK|BAN + + private fun edition( + kind: ControlEntityKind, + entity: String, + version: Long, + prev: ByteArray?, + content: String, + author: String, + rumorId: String, + ) = ControlEdition(kind, entity.hexToByteArray(), version, prev, null, content, author, rumorId, 0) + + private fun role( + id: String, + json: String, + author: String = owner, + version: Long = 0, + prev: ByteArray? = null, + ) = edition(ControlEntityKind.ROLE, id, version, prev, json, author, "role-$id-$version-$author") + + private fun grant( + coordinate: String, + member: String, + roleIds: List, + author: String, + version: Long = 0, + prev: ByteArray? = null, + ) = edition( + ControlEntityKind.GRANT, + coordinate, + version, + prev, + """{"member":"$member","role_ids":[${roleIds.joinToString(",") { "\"$it\"" }}]}""", + author, + "grant-$coordinate-$version-$author", + ) + + private fun banlist( + author: String, + version: Long, + prev: ByteArray?, + vararg banned: String, + ) = edition( + ControlEntityKind.BANLIST, + banlistEntity, + version, + prev, + "[${banned.joinToString(",") { "\"$it\"" }}]", + author, + "ban-$version-$author", + ) + + private fun channel( + json: String, + author: String, + version: Long, + prev: ByteArray?, + ) = edition(ControlEntityKind.CHANNEL, channelEntity, version, prev, json, author, "chan-$version-$author") + + private fun metadata( + json: String, + author: String, + version: Long, + prev: ByteArray?, + ) = edition(ControlEntityKind.METADATA, metadataEntity, version, prev, json, author, "meta-$version-$author") + + private val channelV0 = channel("""{"name":"general"}""", owner, 0, null) + private val metadataV0 = metadata("""{"name":"My Community"}""", owner, 0, null) + private val bobGrantV0 = grant(bobGrantEntity, bob, listOf(modRole), owner) + private val modRoleV0 = role(modRole, modJson) + + /** The owner-authored community every test starts from: two roles, two grants, a channel, metadata. */ + private fun community() = + mutableListOf( + role(adminRole, adminJson), + modRoleV0, + grant("31".repeat(32), alice, listOf(adminRole), owner), + bobGrantV0, + channelV0, + metadataV0, + ) + + /** The owner bans alice. Genesis of the banlist, so every test can fork or chain off it. */ + private val ownerBansAlice = banlist(owner, 0, null, alice) + + /** Alice, already banned, mints a role just below herself and hands it to a fresh npub. */ + private fun aliceMintsAPuppet() = + listOf( + role(puppetRole, """{"name":"Puppet","position":2,"permissions":"95"}""", author = alice), + grant(puppetGrantEntity, puppet, listOf(puppetRole), author = alice), + ) + + @Test + fun aBanStripsTheAuthorityCheckedByFoldButNotTheOneCheckedByTheResolver() { + val r = AuthorityResolver.resolve(community() + ownerBansAlice, owner) + + assertTrue(r.isBanned(alice), "the owner's ban lands") + assertFalse(r.hasPermission(alice, ConcordPermissions.MANAGE_ROLES), "the ban-aware check refuses her") + // effectivePermissions still reports what her ROLES say — that is its job, and the members + // screen reads it to label her. What changed is that the resolver's own ROLE/GRANT/BANLIST + // gates no longer consult it for a banned author; they drop the edition outright. + assertTrue( + r.effectivePermissions(alice).has(ConcordPermissions.MANAGE_ROLES), + "the role-derived view is unchanged — only what it authorizes is", + ) + } + + @Test + fun aBannedAdminPromotesAFreshSockpuppetToAdmin() { + val r = AuthorityResolver.resolve(community() + ownerBansAlice + aliceMintsAPuppet(), owner) + + assertEquals(null, r.rank(puppet), "the banned admin's role and grant editions are both dropped") + assertFalse(r.isBanned(puppet), "the puppet itself is a clean npub — it is never banned, just powerless") + assertFalse( + r.hasPermission(puppet, ConcordPermissions.MANAGE_CHANNELS), + "a banned member cannot mint authority it no longer has to give", + ) + } + + @Test + fun theSockpuppetDeletesEveryChannelAndRewritesTheMetadata() { + val editions = + community() + ownerBansAlice + aliceMintsAPuppet() + + // A channel tombstone is terminal — CORD-03: the id is never reused. + channel("""{"name":"general","deleted":true}""", puppet, 1, channelV0.hash) + + metadata("""{"name":"Owned by the guy you banned"}""", puppet, 1, metadataV0.hash) + + val state = ConcordCommunityState.fold(editions, owner) + + assertEquals(1, state.channels.size, "the puppet holds nothing, so its tombstone is inert") + assertEquals("My Community", state.metadata?.name, "and the community keeps its identity") + } + + @Test + fun theSockpuppetBansEveryMemberBeneathIt() { + val editions = community() + ownerBansAlice + aliceMintsAPuppet() + banlist(puppet, 1, ownerBansAlice.hash, alice, bob, carol) + + val r = AuthorityResolver.resolve(editions, owner) + + assertFalse(r.isBanned(bob), "the puppet's banlist edition is unauthorized, so the moderator stands") + assertFalse(r.isBanned(carol), "and so do the plain members") + } + + @Test + fun aBannedAdminBansEveryoneBeneathThemWithoutNeedingAPuppetAtAll() { + val editions = community() + ownerBansAlice + banlist(alice, 1, ownerBansAlice.hash, alice, bob, carol) + + val r = AuthorityResolver.resolve(editions, owner) + + assertTrue(r.isBanned(alice), "her own ban stands — it was the owner's") + assertFalse(r.isBanned(bob), "banGate now drops a banned author's edition outright") + assertFalse(r.isBanned(carol), "same") + } + + @Test + fun aBannedAdminForksTheBanlistAtGenesisRatherThanChainingOntoTheirOwnBan() { + // The same attack as above, except her edition does NOT chain onto the edition that banned + // her — it forks at genesis. So a rule that only asks "was the author banned by this + // edition's parent?" never sees her ban, and CORD-04 §4's re-heal union carries her bans in + // regardless. Any fix has to bind the union, not just the chain. + val editions = community() + ownerBansAlice + banlist(alice, 0, null, bob, carol) + + val r = AuthorityResolver.resolve(editions, owner) + + assertTrue(r.isBanned(alice), "the owner's ban survives the fork — the union is down-only") + assertFalse(r.isBanned(bob), "the fix binds the UNION too: her fork is dropped before it can be healed in") + assertFalse(r.isBanned(carol), "same") + } + + @Test + fun aBannedAdminRevokesTheSurvivingModerators() { + val editions = community() + ownerBansAlice + grant(bobGrantEntity, bob, emptyList(), author = alice, version = 1, prev = bobGrantV0.hash) + + val r = AuthorityResolver.resolve(editions, owner) + + assertEquals(5, r.rank(bob), "a banned admin's revoke is dropped, so the moderator keeps their role") + assertTrue(r.hasPermission(bob, ConcordPermissions.BAN), "and keeps the authority that comes with it") + } + + @Test + fun aBannedAdminDeletesEveryRoleBeneathThem() { + val tombstone = role(modRole, """{"name":"Mod","position":5,"permissions":"24","deleted":true}""", author = alice, version = 1, prev = modRoleV0.hash) + + val r = AuthorityResolver.resolve(community() + ownerBansAlice + tombstone, owner) + + assertEquals(5, r.roles()[modRole]?.position, "a banned admin's tombstone is dropped, so the role survives") + assertEquals(5, r.rank(bob), "and its holders keep their standing") + } + + @Test + fun selfUnbanIsStillRefused() { + // docs/concord-banlist-rank-conformance.md §4 row 3, the half that IS closed: the delta rule + // gates removals too, and strict outranking means nobody outranks themselves. + val editions = community() + ownerBansAlice + banlist(alice, 1, ownerBansAlice.hash) + + assertTrue(AuthorityResolver.resolve(editions, owner).isBanned(alice), "a banned member may not lift their own ban") + } + + @Test + fun aJuniorPuppetCannotLiftASeniorsBan() { + // The puppet sits at position 2 and alice at 1, and no edition may claim a position at or + // above its own signer — so her delegation chain can only ever descend. Nothing she mints + // can outrank her, and so nothing she mints can unban her. + val editions = community() + ownerBansAlice + aliceMintsAPuppet() + banlist(puppet, 1, ownerBansAlice.hash) + + assertTrue(AuthorityResolver.resolve(editions, owner).isBanned(alice), "the puppet does not outrank its creator") + } + + @Test + fun aForkedBanlistThatOmitsHimCannotLaunderTheBanAway() { + // A malicious client is not limited to what the ban/unban verb will author. The sharpest + // hand-crafted route does not try to REMOVE his ban — removal is what the strict-outrank-self + // rule guards — it forks at genesis and simply never mentions him, at a version high enough + // to win the head fold. The head's own effective list then never carried his ban, so there is + // nothing to remove and the rank rule never fires. + // + // §4's re-heal is what closes it: the owner's edition is authorized and is NOT on the forked + // head's back-chain, so it is unioned back in as a concurrent ban. + val editions = community() + ownerBansAlice + banlist(alice, 99, null, carol) + + assertTrue(AuthorityResolver.resolve(editions, owner).isBanned(alice), "the re-heal union must put the owner's ban back") + } + + @Test + fun aPrivateBanlistChainOfHisOwnCannotLaunderTheBanAway() { + // The same idea two editions deep, so the winning head has a clean ancestry entirely of his + // own making. Ancestry is walked over the full pool, so the owner's ban is still recognised + // as a concurrent fork rather than a superseded ancestor. + val mine = banlist(alice, 50, null) + val editions = community() + ownerBansAlice + mine + banlist(alice, 51, mine.hash) + + assertTrue(AuthorityResolver.resolve(editions, owner).isBanned(alice), "a self-authored chain must not launder the ban away") + } + + @Test + fun aRogueRotatorCompactsTheBanAwayForEveryClientWithoutAFloor() { + // The route that does work, and the one no signature check can catch. A CORD-06 §3 compaction + // re-wraps ONE edition per entity and the ROTATOR picks it, so a rotator can simply not carry + // the banlist forward. Every edition it serves is genuine; the ban is erased by omission. + // + // A banned member cannot rotate (drainConcordRekeys gates the rotator on hasPermission, which + // is ban-aware) — but the puppet minted above is not banned, and it can. EntityFloor is the + // whole defense, so this splits the community in two: clients that already folded the ban + // refuse the rollback, while fresh joiners have no floor to refuse with and see no ban at all. + val editions = community() + ownerBansAlice + val floors = ConcordCommunityState.authorizedHeads(editions, owner) + val compacted = editions.filter { it.entityKind != ControlEntityKind.BANLIST } + + assertFalse( + ConcordCommunityState.fold(compacted, owner).authority.isBanned(alice), + "ESCALATION: a fresh joiner holds no floor, so the omitted ban simply never existed", + ) + assertTrue( + ConcordCommunityState.fold(compacted, owner, floors).authority.isBanned(alice), + "a client that already folded the ban must refuse the rollback", + ) + } + + @Test + fun banningAnAdminAlsoDemotesEveryoneThatAdminPromoted() { + // The deliberate cascade, pinned because it is surprising and because it is the whole point. + // CORD-04 §4 drops every event from a banned npub, authority actions included, so a grant + // they made while in good standing goes too. That is what kills a sockpuppet minted moments + // before the ban — and the same rule costs the owner a legitimate promotion, which they have + // to re-issue. See B2 in docs/concord-soft-ban-audit.md. + val promoted = grant("36".repeat(32), carol, listOf(modRole), author = alice) + + val before = AuthorityResolver.resolve(community() + promoted, owner) + assertEquals(5, before.rank(carol), "while alice is in good standing, her grant stands") + + val after = AuthorityResolver.resolve(community() + promoted + ownerBansAlice, owner) + assertEquals(null, after.rank(carol), "banning alice retroactively drops the grant she authored") + } + + @Test + fun aBanByOneAdminDoesNotDropTheGrantsOfAnother() { + // The cascade must follow the banned author, not spread. Bob is untouched by alice's ban, so + // everything he authored keeps standing. + val carolByBob = grant("37".repeat(32), carol, listOf(modRole), author = bob) + // bob is a Mod at position 5 and the role he hands out is that same position, so the grant is + // only honored when authored by someone who outranks it — the owner does, bob does not. + val carolByOwner = grant("38".repeat(32), carol, listOf(modRole), author = owner) + + val r = AuthorityResolver.resolve(community() + ownerBansAlice + carolByBob + carolByOwner, owner) + + assertTrue(r.isBanned(alice), "alice is the only one banned") + assertEquals(5, r.rank(bob), "bob is untouched") + assertEquals(5, r.rank(carol), "and the owner's grant of carol stands") + } + + @Test + fun aMemberReleasedByTheSecondPassKeepsTheEditionsTheyAuthored() { + // The mask a pass resolves UNDER has to equal the banlist that pass produces, or the fold + // reports a state that contradicts itself. Concretely: the rogue admin bans a moderator while + // the owner concurrently bans the rogue. The moderator is correctly released — the only ban on + // her came from someone who turned out to be banned — but a fold that stops after two passes + // has already dropped her editions, because she was on the FIRST pass's list. She then reads + // as a moderator in good standing whose promotions silently vanished, deterministically and + // forever. resolve() iterates until the two agree. + val juniorRole = "23".repeat(32) + val seniorRole = "24".repeat(32) + val editions = + community() + + // the baseline Mod role carries no MANAGE_ROLES, so give bob one that can grant + role(seniorRole, """{"name":"Senior","position":5,"permissions":"95"}""") + + grant(bobGrantEntity, bob, listOf(seniorRole), author = owner, version = 1, prev = bobGrantV0.hash) + + role(juniorRole, """{"name":"Junior","position":9,"permissions":"8"}""") + + // bob promotes carol himself, while in good standing + grant("39".repeat(32), carol, listOf(juniorRole), author = bob) + + // the rogue admin bans bob... + banlist(alice, 0, null, bob) + + // ...while the owner concurrently bans the rogue, never naming bob + ownerBansAlice + + val r = AuthorityResolver.resolve(editions, owner) + + assertTrue(r.isBanned(alice), "the owner's ban of the rogue stands") + assertFalse(r.isBanned(bob), "and the rogue's ban of the moderator falls with them") + assertEquals(5, r.rank(bob), "the released moderator keeps their own role") + assertEquals(9, r.rank(carol), "and the promotion they authored survives with them") + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/ControlPlaneVersionExhaustionTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/ControlPlaneVersionExhaustionTest.kt new file mode 100644 index 0000000000..c97ed0dba9 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/ControlPlaneVersionExhaustionTest.kt @@ -0,0 +1,223 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.concord.cord04Roles + +import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityState +import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * **B1 in `docs/concord-soft-ban-audit.md` — regression guard.** A single Control Plane edition at + * `version = Long.MAX_VALUE` used to pin its entity to the author's content permanently, for every + * client that held a floor for it. These tests failed before the fix and pass after it. + * + * The chain walk was never the weakness — it advances only to `head.version + 1` citing the head's + * hash, so an inflated version is unreachable and a fresh joiner was unaffected. The weakness was the + * **compaction arm** of `EditionFold.foldEntity`: once a client held a floor for an entity and that + * entity appeared in the epoch snapshot (which `ConcordCommunityState.fold` always builds from the + * editions handed to it), the head came from the raw-version bootstrap — *highest version at or above + * the floor*, with no `prev`, no hash, and no contiguity. Version was then the whole contest, and + * `Long.MAX_VALUE` won it forever: + * + * 1. the poison became the head, so the entity showed the attacker's content; + * 2. `authorizedHeads` raised the entity's floor to `Long.MAX_VALUE`; + * 3. no honest edition could ever exceed that floor, so the entity could never be repaired; + * 4. a Refounding that dropped the poison did not help either — nothing was then offered at or above + * the floor, so the fold reported a gap and fell back to `EntityFloor.known`, which *was* the poison. + * + * Two changes close it, and both are pinned below. The arm now tries the floor-anchored **chain** + * first and only falls back to the raw-version bootstrap when nothing connects, so a stray never wins + * a fold where the honest chain is present; and the bootstrap will not follow a jump larger than + * [EditionFold.MAX_COMPACTION_VERSION_JUMP], so the version space cannot be exhausted in one step. + * [aGenuineCompactionJumpIsStillFollowed] pins the tolerance the arm exists for, so the bound cannot + * be tightened into breaking CORD-06 §3. + * + * Note who the attacker is. Every test here is authored by **bob, a current and legitimately granted + * moderator** — not a banned member, not a sockpuppet. Any holder of the entity's permission bit can + * could do this at any time, and demoting or banning them afterwards changed nothing, because the + * damage was already in every client's floor. `ConcordRefounding.compactControlPlane` also selected + * the head per entity by raw highest version, which made an honest rotator the delivery mechanism — + * it now picks the chain head instead. + * + * The banlist was the one entity that survived, and by accident: `AuthorityResolver` folds it with + * its own floor-less chain walk and then re-heals the union across authorized editions, so an + * honest ban landed even when the head was poisoned. [aPoisonedBanlistStillAcceptsTheOwnersBan] + * keeps pinning that, because it was the only thing standing between this bug and a permanently + * unmoderatable community. + */ +class ControlPlaneVersionExhaustionTest { + private val owner = "0f".repeat(32) + private val bob = "b2".repeat(32) + + private val modRole = "22".repeat(32) + private val metadataEntity = "66".repeat(32) + private val channelEntity = "55".repeat(32) + private val banlistEntity = "44".repeat(32) + + private fun edition( + kind: ControlEntityKind, + entity: String, + version: Long, + prev: ByteArray?, + content: String, + author: String, + rumorId: String, + ) = ControlEdition(kind, entity.hexToByteArray(), version, prev, null, content, author, rumorId, 0) + + /** bob holds exactly one bit, granted by the owner, entirely legitimately. */ + private fun communityWhereBobHolds( + permissions: String, + vararg rest: ControlEdition, + ) = listOf( + edition(ControlEntityKind.ROLE, modRole, 0, null, """{"name":"Mod","position":5,"permissions":"$permissions"}""", owner, "role-mod"), + edition(ControlEntityKind.GRANT, "32".repeat(32), 0, null, """{"member":"$bob","role_ids":["$modRole"]}""", owner, "grant-bob"), + ) + rest + + @Test + fun oneEditionAtMaxVersionNoLongerPinsTheMetadata() { + val metadataV0 = edition(ControlEntityKind.METADATA, metadataEntity, 0, null, """{"name":"My Community"}""", owner, "meta-0") + val community = communityWhereBobHolds(ConcordPermissions.of(ConcordPermissions.MANAGE_METADATA).toWire(), metadataV0) + + // A client that has folded this community once holds a floor for the metadata entity. + val floorsBefore = ConcordCommunityState.authorizedHeads(community, owner) + assertEquals(0, floorsBefore[metadataEntity]?.version, "an ordinary floor at the genesis edition") + + val poison = edition(ControlEntityKind.METADATA, metadataEntity, Long.MAX_VALUE, metadataV0.hash, """{"name":"PWNED"}""", bob, "meta-poison") + val floorsAfter = ConcordCommunityState.authorizedHeads(community + poison, owner, floorsBefore) + assertEquals(0, floorsAfter[metadataEntity]?.version, "the floor must not follow a stray to the top of the version space") + + // The owner tries to repair it, chaining honestly onto their own genesis. + val repair = edition(ControlEntityKind.METADATA, metadataEntity, 1, metadataV0.hash, """{"name":"My Community"}""", owner, "meta-1") + val pool = community + poison + repair + + assertEquals( + "My Community", + ConcordCommunityState.fold(pool, owner).metadata?.name, + "a fresh joiner walks the chain and is unaffected", + ) + assertEquals( + "My Community", + ConcordCommunityState.fold(pool, owner, floorsAfter).metadata?.name, + "a client holding a floor follows the honest chain, not the stray", + ) + assertEquals( + "My Community", + ConcordCommunityState.fold(community + repair, owner, floorsAfter).metadata?.name, + "and a Refounding that drops the poison stays repaired", + ) + } + + @Test + fun oneEditionAtMaxVersionNoLongerDeletesAChannel() { + val channelV0 = edition(ControlEntityKind.CHANNEL, channelEntity, 0, null, """{"name":"general"}""", owner, "chan-0") + val community = communityWhereBobHolds(ConcordPermissions.of(ConcordPermissions.MANAGE_CHANNELS).toWire(), channelV0) + + val floorsBefore = ConcordCommunityState.authorizedHeads(community, owner) + val poison = edition(ControlEntityKind.CHANNEL, channelEntity, Long.MAX_VALUE, channelV0.hash, """{"name":"general","deleted":true}""", bob, "chan-poison") + val floorsAfter = ConcordCommunityState.authorizedHeads(community + poison, owner, floorsBefore) + + val repair = edition(ControlEntityKind.CHANNEL, channelEntity, 1, channelV0.hash, """{"name":"general"}""", owner, "chan-1") + val pool = community + poison + repair + + assertEquals(1, ConcordCommunityState.fold(pool, owner).channels.size, "a fresh joiner still sees the channel") + assertEquals(1, ConcordCommunityState.fold(pool, owner, floorsAfter).channels.size, "and so does a client holding a floor") + assertEquals( + 1, + ConcordCommunityState.fold(community + repair, owner, floorsAfter).channels.size, + "the channel survives a Refounding too", + ) + } + + @Test + fun aPoisonedBanlistStillAcceptsTheOwnersBan() { + // This was the saving grace before the fix — the reason the bug was "community with a broken + // name" rather than "community nobody can moderate". AuthorityResolver folds the banlist on + // its own floor-less chain walk and re-heals the union across every authorized edition, so + // the owner's ban landed even while the banlist's floor sat at Long.MAX_VALUE. The floor can + // no longer be poisoned, but keep this: do not "unify" the banlist onto the floored fold + // without replacing the protection. + val banlistV0 = edition(ControlEntityKind.BANLIST, banlistEntity, 0, null, "[]", owner, "ban-0") + val community = communityWhereBobHolds(ConcordPermissions.of(ConcordPermissions.BAN).toWire(), banlistV0) + + val floorsBefore = ConcordCommunityState.authorizedHeads(community, owner) + val poison = edition(ControlEntityKind.BANLIST, banlistEntity, Long.MAX_VALUE, banlistV0.hash, "[]", bob, "ban-poison") + val floorsAfter = ConcordCommunityState.authorizedHeads(community + poison, owner, floorsBefore) + assertEquals(0, floorsAfter[banlistEntity]?.version, "the banlist floor is no longer poisonable either") + + val ownerBansBob = edition(ControlEntityKind.BANLIST, banlistEntity, 1, banlistV0.hash, """["$bob"]""", owner, "ban-1") + val pool = community + poison + ownerBansBob + + assertTrue(ConcordCommunityState.fold(pool, owner).authority.isBanned(bob), "a fresh joiner honors the ban") + assertTrue( + ConcordCommunityState.fold(pool, owner, floorsAfter).authority.isBanned(bob), + "the re-heal union must keep the banlist working even with a poisoned floor", + ) + } + + @Test + fun aGenuineCompactionJumpIsStillFollowed() { + // The tolerance the compaction arm exists for, pinned so the bound above cannot be tightened + // into breaking CORD-06 §3. After a Refounding the compacted head carries the `prev` it had + // before compaction, citing an edition in the PRIOR epoch that this client no longer holds — + // so it connects to nothing, and its version is legitimately several ahead of our floor. + val metadataV0 = edition(ControlEntityKind.METADATA, metadataEntity, 0, null, """{"name":"My Community"}""", owner, "meta-0") + val community = communityWhereBobHolds(ConcordPermissions.of(ConcordPermissions.MANAGE_METADATA).toWire(), metadataV0) + val floors = ConcordCommunityState.authorizedHeads(community, owner) + + val danglingPrev = ByteArray(32) { 0x7f } + val compacted = edition(ControlEntityKind.METADATA, metadataEntity, 4, danglingPrev, """{"name":"Renamed While We Were Away"}""", owner, "meta-compacted") + + assertEquals( + "Renamed While We Were Away", + ConcordCommunityState.fold(community.filter { it.entityKind != ControlEntityKind.METADATA } + compacted, owner, floors).metadata?.name, + "a compacted head whose prev dangles by design must still be adopted", + ) + } + + @Test + fun aJumpBeyondTheCapIsRefusedAsAGap() { + // Same shape as the genuine compaction above, one version past the bound: not a compaction we + // missed, so the fold reports a gap and keeps what it already had rather than following it. + val metadataV0 = edition(ControlEntityKind.METADATA, metadataEntity, 0, null, """{"name":"My Community"}""", owner, "meta-0") + val community = communityWhereBobHolds(ConcordPermissions.of(ConcordPermissions.MANAGE_METADATA).toWire(), metadataV0) + val floors = ConcordCommunityState.authorizedHeads(community, owner) + + val danglingPrev = ByteArray(32) { 0x7f } + val tooFar = + edition( + ControlEntityKind.METADATA, + metadataEntity, + EditionFold.MAX_COMPACTION_VERSION_JUMP + 1, + danglingPrev, + """{"name":"PWNED"}""", + bob, + "meta-far", + ) + + assertEquals( + "My Community", + ConcordCommunityState.fold(community.filter { it.entityKind != ControlEntityKind.METADATA } + tooFar, owner, floors).metadata?.name, + "a jump past the bound is a gap, not a head", + ) + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordInviteClassifyTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordInviteClassifyTest.kt index 95329c3b98..b0ac0bf1a9 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordInviteClassifyTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordInviteClassifyTest.kt @@ -29,6 +29,7 @@ import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray import com.vitorpamplona.quartz.nip01Core.core.toHexKey import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.crypto.verify import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal import kotlinx.coroutines.test.runTest import kotlin.test.Test @@ -152,6 +153,40 @@ class ConcordInviteClassifyTest { assertEquals(InviteBundleStatus.Absent, ConcordInviteBundle.classify(emptyList(), ByteArray(16))) } + @Test + fun buildRevocationEmitsTheWireShapeArmadaEmits() = + runTest { + val community = ConcordCommunityFactory.create(owner, "Nostrichs", createdAt = 1L, relays = listOf("wss://relay.example")) + val minted = ConcordInviteBundle.mintLink("https://vector.chat", inviteFor(community), createdAt = 1L, relays = listOf("wss://relay.example")) + + val grave = ConcordInviteBundle.buildRevocation(minted.linkSignerPrivKey, createdAt = 2L) + + // The interop contract, byte for byte: kind 33301 at the SAME addressable coordinate + // (same author, same empty d tag), empty content, vsk=9. Anything else here and a + // non-Amethyst client keeps serving a link its creator believes is dead. + assertEquals(ConcordInviteBundleEvent.KIND, grave.kind) + assertEquals(minted.linkSignerPubKey, grave.pubKey, "a tombstone at a different author retires nothing") + assertEquals("", grave.content, "the grave carries no keys — nothing to encrypt") + assertEquals(listOf(listOf("d", ""), listOf("vsk", "9")), grave.tags.map { it.toList() }) + assertTrue(grave.verify(), "must be signed by the link signer the creator kept") + } + + @Test + fun aBuiltRevocationRetiresItsOwnLink() = + runTest { + val community = ConcordCommunityFactory.create(owner, "Nostrichs", createdAt = 1L, relays = listOf("wss://relay.example")) + val minted = ConcordInviteBundle.mintLink("https://vector.chat", inviteFor(community), createdAt = 1L, relays = listOf("wss://relay.example")) + + // End to end: what the creator publishes is what every redeemer then resolves. + val grave = ConcordInviteBundle.buildRevocation(minted.linkSignerPrivKey, createdAt = 2L) + assertEquals(InviteBundleStatus.Revoked, ConcordInviteBundle.classify(listOf(minted.bundleEvent, grave), minted.token)) + + // And a re-mint that lands AFTER the grave un-revokes the link, which is exactly why the + // refresh path must skip a coordinate it did not resolve Live first. + val remint = ConcordInviteBundle.build(minted.linkSignerPrivKey, minted.token, inviteFor(community), createdAt = 3L) + assertTrue(ConcordInviteBundle.classify(listOf(minted.bundleEvent, grave, remint), minted.token) is InviteBundleStatus.Live) + } + @Test fun realRelayopBundleIsUnreadable() { // The actual kind-33301 event behind the reported relayop.xyz/invite link (vsk=8), plus the diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordInviteListTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordInviteListTest.kt new file mode 100644 index 0000000000..b3a6dbde5e --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordInviteListTest.kt @@ -0,0 +1,197 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.concord.cord05Invites + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * Wire conformance for the CORD-05 Invite List (kind 13303). The whole point of this document is + * cross-client: a link minted in Armada must be refreshable from Amethyst and back, so the field + * names and the merge key are contract, not preference. + */ +class ConcordInviteListTest { + // The spec's own example document, verbatim in shape. + private val specJson = + """ + { "entries": [ + { "token": "aa11", + "signer_sk": "bb22", + "community_id": "cc33", + "url": "https://vector.chat/invite/naddr1abc#frag", + "label": "Reddit", + "created_at": 1719800000, + "expires_at": 1722400000 } ], + "tombstones": [ { "token": "dd44", "community_id": "cc33" } ] } + """.trimIndent() + + @Test + fun readsTheSpecDocumentIntoTypedEntries() { + val doc = ConcordInviteList.decodeOrNull(specJson)!! + + assertEquals(1, doc.entries.size) + val e = doc.entries.first() + assertEquals("aa11", e.token) + assertEquals("bb22", e.signerSk) + assertEquals("cc33", e.communityId) + assertEquals("https://vector.chat/invite/naddr1abc#frag", e.url) + assertEquals("Reddit", e.label) + assertEquals(1719800000L, e.createdAt) + assertEquals(1722400000L, e.expiresAt) + + assertEquals(1, doc.tombstones.size) + assertEquals("dd44", doc.tombstones.first().token) + assertEquals("cc33", doc.tombstones.first().communityId) + } + + @Test + fun emitsTheSnakeCaseKeysAnotherClientReads() { + val json = ConcordInviteList.encode(ConcordInviteList.decodeOrNull(specJson)!!) + // Field names are the interop contract — a camelCase slip silently orphans every link. + for (key in listOf("\"token\"", "\"signer_sk\"", "\"community_id\"", "\"url\"", "\"created_at\"", "\"expires_at\"", "\"entries\"", "\"tombstones\"")) { + assertTrue(json.contains(key), "missing wire key $key") + } + } + + @Test + fun keepsUnknownKeysAcrossADecodeEncodeCycle() { + // Armada types the entry and tombstone as `[k: string]: unknown`, so dropping a key we do + // not model deletes another client's data on our next publish. + val withExtras = + """ + { "entries": [ { "token": "aa11", "signer_sk": "bb22", "community_id": "cc33", + "url": "u", "created_at": 1, "future_field": {"a":1} } ], + "tombstones": [ { "token": "dd44", "community_id": "cc33", "why": "revoked" } ], + "doc_level_unknown": 7 } + """.trimIndent() + + val round = ConcordInviteList.encode(ConcordInviteList.decodeOrNull(withExtras)!!) + + assertTrue(round.contains("future_field"), "entry-level unknown key dropped") + assertTrue(round.contains("doc_level_unknown"), "document-level unknown key dropped") + assertTrue(round.contains("\"why\""), "tombstone unknown key dropped") + } + + @Test + fun mergesByTokenAndLetsTombstonesWin() { + val base = + ConcordInviteListDocument( + entries = + listOf( + ConcordInviteListEntry("t1", "sk1", "c", "url1", createdAt = 1), + ConcordInviteListEntry("t2", "sk2", "c", "url2", createdAt = 2), + ), + ) + // Another device minted t3 and retired t1. + val patch = + ConcordInviteListDocument( + entries = listOf(ConcordInviteListEntry("t3", "sk3", "c", "url3", createdAt = 3)), + tombstones = listOf(ConcordInviteListTombstone("t1", "c")), + ) + + val merged = ConcordInviteList.merge(base, patch) + val tokens = merged.entries.map { it.token }.toSet() + + assertEquals(setOf("t2", "t3"), tokens, "merge is keyed by token; a tombstoned link is dropped") + assertTrue(merged.tombstones.any { it.token == "t1" }, "the tombstone must persist or a stale device resurrects the link") + } + + @Test + fun aMalformedDocumentYieldsNullSoCallersCannotOverwriteWithIt() { + // Null, not empty: a caller that republishes an "empty" list over this replaceable + // coordinate destroys every signer_sk it failed to read. + assertEquals(null, ConcordInviteList.decodeOrNull("not json")) + assertEquals(null, ConcordInviteList.decodeOrNull("{\"entries\":\"wrong type\"}")) + } + + @Test + fun oneUnreadableEntryDoesNotFailTheWholeDocumentOrGetDropped() { + // One structurally incompatible entry — a newer schema turning a scalar into an object, the + // realistic version, since the lenient parser already coerces plain scalar mismatches — used + // to null the whole document. Because null now means "refuse to write", that turned a single + // odd row into a permanent lock on mint and revoke for the account: a replaceable coordinate + // never ages out, so nothing would ever clear it. + val mixed = + """ + { "entries": [ + { "token": "aa", "signer_sk": "bb", "community_id": "cc", "url": "u1" }, + { "token": {"v": "dd"}, "signer_sk": "dd", "community_id": "cc", "url": "u2", "mark": "keepme" } + ], + "tombstones": [] } + """.trimIndent() + + val doc = ConcordInviteList.decodeOrNull(mixed) + assertEquals(listOf("aa"), doc!!.entries.map { it.token }, "the readable entry still decodes") + assertEquals(1, doc.opaqueEntries.size, "the unreadable entry is kept, not discarded") + + // And it survives a re-encode: dropping it would delete somebody's signer_sk, which is the + // exact data loss this class exists to prevent. + assertTrue(ConcordInviteList.encode(doc).contains("keepme"), "unreadable entry lost on re-encode") + } + + @Test + fun aMergeCarriesUnreadableEntriesThrough() { + val base = ConcordInviteList.decodeOrNull("""{"entries":[{"token":{"v":7},"mark":"opaque"}],"tombstones":[]}""")!! + val patch = ConcordInviteListDocument(entries = listOf(ConcordInviteListEntry("t", "sk", "c", "u"))) + + val merged = ConcordInviteList.merge(base, patch) + + assertEquals(listOf("t"), merged.entries.map { it.token }) + // We cannot read it, so we are in no position to decide it is disposable. + assertTrue(ConcordInviteList.encode(merged).contains("opaque"), "merge dropped an unreadable entry") + } + + @Test + fun anUnreadableListIsDistinguishableFromAnEmptyOne() { + // The whole point of the null: a caller must be able to tell "I could not read it" from + // "there is nothing in it". Publishing a merge onto the latter is fine; onto the former it + // destroys every signer_sk on this replaceable coordinate. + assertEquals(null, ConcordInviteList.decodeOrNull("")) + + val empty = ConcordInviteList.decodeOrNull("""{"entries":[],"tombstones":[]}""") + assertEquals(0, empty!!.entries.size, "a genuinely empty list decodes, it does not fail") + + // And a merge onto an empty base keeps the patch, so starting a first list still works. + val patch = ConcordInviteListDocument(entries = listOf(ConcordInviteListEntry("t", "sk", "c", "u"))) + assertEquals(listOf("t"), ConcordInviteList.merge(empty, patch).entries.map { it.token }) + } + + @Test + fun theSignerPubKeyIsTheCoordinateTheBundleLivesAt() { + // Refreshing or revoking a link means writing at exactly this author, so it must derive from + // the secret we kept rather than being stored (and drifting) separately. + val sk = "11".repeat(32) + val entry = ConcordInviteListEntry("t", sk, "c", "u") + assertEquals(64, entry.signerPubKeyHex().length) + assertEquals(entry.signerPubKeyHex(), ConcordInviteListEntry("t2", sk, "c", "u2").signerPubKeyHex()) + } + + @Test + fun anExpiredLinkIsNotRefreshable() { + val live = ConcordInviteListEntry("t", "sk", "c", "u", expiresAt = 100) + val forever = ConcordInviteListEntry("t", "sk", "c", "u", expiresAt = null) + + assertTrue(live.isExpired(nowSecs = 101), "an elapsed link can no longer be joined") + assertTrue(!live.isExpired(nowSecs = 99)) + assertTrue(!forever.isExpired(nowSecs = Long.MAX_VALUE), "no expiry means it never elapses") + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordStrandedRecoveryTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordStrandedRecoveryTest.kt index 6c0f9aa59c..96c1124e57 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordStrandedRecoveryTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordStrandedRecoveryTest.kt @@ -82,7 +82,7 @@ class ConcordStrandedRecoveryTest { val prior = HeldRoot(0L, "aa".repeat(32)) val stranded = entry(epoch = 1, heldRoots = listOf(prior)) - val merged = ConcordStrandedRecovery.mergeForward(stranded, bundle(epoch = 5)) + val merged = ConcordStrandedRecovery.mergeForward(stranded, bundle(epoch = 5), bannedAtCurrentEpoch = false) assertNotNull(merged, "a higher-epoch bundle at our own invite link means we were left behind") // adopted the new epoch's access root @@ -106,27 +106,27 @@ class ConcordStrandedRecoveryTest { @Test fun sameEpochBundleIsANoOp() { - assertNull(ConcordStrandedRecovery.mergeForward(entry(epoch = 5), bundle(epoch = 5))) - assertFalse(ConcordStrandedRecovery.isStranded(entry(epoch = 5), bundle(epoch = 5))) + assertNull(ConcordStrandedRecovery.mergeForward(entry(epoch = 5), bundle(epoch = 5), bannedAtCurrentEpoch = false)) + assertFalse(ConcordStrandedRecovery.isStranded(entry(epoch = 5), bundle(epoch = 5), bannedAtCurrentEpoch = false)) } @Test fun lowerEpochBundleIsANoOp() { // Epoch-monotonic: a stale bundle must never walk the membership backwards. - assertNull(ConcordStrandedRecovery.mergeForward(entry(epoch = 7), bundle(epoch = 3))) + assertNull(ConcordStrandedRecovery.mergeForward(entry(epoch = 7), bundle(epoch = 3), bannedAtCurrentEpoch = false)) } @Test fun entryWithoutInviteRefIsInert() { // Direct invites and legacy entries have no anchor — expected, not an error. val noAnchor = entry(epoch = 1, ref = null) - assertFalse(ConcordStrandedRecovery.isStranded(noAnchor, bundle(epoch = 9))) - assertNull(ConcordStrandedRecovery.mergeForward(noAnchor, bundle(epoch = 9))) + assertFalse(ConcordStrandedRecovery.isStranded(noAnchor, bundle(epoch = 9), bannedAtCurrentEpoch = false)) + assertNull(ConcordStrandedRecovery.mergeForward(noAnchor, bundle(epoch = 9), bannedAtCurrentEpoch = false)) } @Test fun bundleForAnotherCommunityIsIgnored() { - assertNull(ConcordStrandedRecovery.mergeForward(entry(epoch = 1), bundle(epoch = 9, id = "99".repeat(32)))) + assertNull(ConcordStrandedRecovery.mergeForward(entry(epoch = 1), bundle(epoch = 9, id = "99".repeat(32)), bannedAtCurrentEpoch = false)) } // ---- the bare `#` anchor form ---------------------------- @@ -252,4 +252,16 @@ class ConcordStrandedRecoveryTest { assertEquals(4L, other.rootEpoch) assertEquals(inviteRef, other.inviteRef) } + + @Test + fun aBannedMemberDoesNotRecoverIntoTheEpochTheyWereRemovedFrom() { + // The removal case the higher-epoch test cannot tell apart on its own: an ex-member keeps the + // link's unlock token forever, so without the ban gate the recovery sweep merges them into the + // very epoch a Refounding rotated them out of. See A2 in docs/concord-soft-ban-audit.md. + val stranded = entry(epoch = 1) + assertFalse(ConcordStrandedRecovery.isStranded(stranded, bundle(epoch = 5), bannedAtCurrentEpoch = true)) + assertNull(ConcordStrandedRecovery.mergeForward(stranded, bundle(epoch = 5), bannedAtCurrentEpoch = true)) + // ...and the legitimate case still works, so the gate is not just "recovery off". + assertNotNull(ConcordStrandedRecovery.mergeForward(stranded, bundle(epoch = 5), bannedAtCurrentEpoch = false)) + } } diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord06Rekey/ConcordRefoundingTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord06Rekey/ConcordRefoundingTest.kt index 06778c4c71..dbc6e9c81d 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord06Rekey/ConcordRefoundingTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord06Rekey/ConcordRefoundingTest.kt @@ -74,6 +74,7 @@ class ConcordRefoundingTest { recipientsXOnly = listOf(alice.pubKey, bob.pubKey), staffXOnly = setOf(owner.pubKey), createdAt = now, + ownerPubKey = owner.pubKey, ) assertEquals(community.rootEpoch + 1, build.newEpoch) @@ -113,6 +114,7 @@ class ConcordRefoundingTest { recipientsXOnly = listOf(alice.pubKey), staffXOnly = setOf(owner.pubKey), createdAt = now, + ownerPubKey = owner.pubKey, ) val newControl = build.newControlKeys @@ -184,6 +186,7 @@ class ConcordRefoundingTest { recipientsXOnly = listOf(alice.pubKey), staffXOnly = setOf(owner.pubKey), createdAt = now, + ownerPubKey = owner.pubKey, ) val newControl = build.newControlKeys @@ -223,6 +226,7 @@ class ConcordRefoundingTest { recipientsXOnly = listOf(alice.pubKey), staffXOnly = setOf(owner.pubKey), createdAt = now, + ownerPubKey = owner.pubKey, ) val baseRekeyKey = ConcordKeyDerivation.baseRekeyAddress(community.communityRoot, community.communityId, build.newEpoch) @@ -230,4 +234,76 @@ class ConcordRefoundingTest { val wrongRoot = ByteArray(32) { 0x11 } assertNull(ConcordRefounding.findNewRoot(build.rekeyWraps, baseRekeyKey, alice, community.communityId, wrongRoot, community.rootEpoch)) } + + @Test + fun compactionRefusesAForgedGenesisAndCarriesTheAuthorizedHead() = + runTest { + // A compaction re-wraps ONE edition per entity and nothing downstream re-checks the + // choice, so how that edition is picked is a security decision, not a detail. + // + // Raw highest-version lets a stray at an arbitrary version through. But the bare + // structural chain walk is worse: with no floor it anchors at the lowest-version edition + // carrying no `prev`, and after a PRIOR compaction the real head's `prev` dangles into a + // trimmed epoch by design — so a forged `version = 1, prev = null` decoy outranks a + // genuine v50→v52 chain, and becomes the entity's entire carried-forward state. A forged + // empty banlist would erase every ban that way. Only the owner-rooted gate is safe. + val community = ConcordCommunityFactory.create(owner, "Test", now) + val communityId = community.communityId + val control = community.controlPlane + + // The metadata entity, already compacted once: its head chains from an epoch we no longer hold. + val danglingPrev = ByteArray(32) { 0x7F } + val realHead = + ConcordStreamEnvelope.wrap( + ControlEditionBuilder.rumor( + owner.pubKey, + ControlEntityKind.METADATA, + communityId, + 50, + danglingPrev, + ConcordJson.instance.encodeToString(MetadataEntity.serializer(), MetadataEntity(name = "Real")), + now, + null, + ), + control, + owner, + encrypted = false, + createdAt = now, + ) + + // carol holds nothing at all and mints a genesis-shaped decoy at version 1. + val forged = + ConcordStreamEnvelope.wrap( + ControlEditionBuilder.rumor( + carol.pubKey, + ControlEntityKind.METADATA, + communityId, + 1, + null, + ConcordJson.instance.encodeToString(MetadataEntity.serializer(), MetadataEntity(name = "PWNED")), + now, + null, + ), + control, + carol, + encrypted = false, + createdAt = now, + ) + + val newEpoch = community.rootEpoch + 1 + val newControl = + com.vitorpamplona.quartz.concord.crypto.ControlPlaneKeys + .forStaff(newRoot, communityId, newEpoch, newControlRoot) + val compacted = ConcordRefounding.compactControlPlane(listOf(realHead, forged), control, newControl, owner.pubKey) + + val carried = + compacted + .mapNotNull { ConcordStreamEnvelope.openOrNull(it, newControl) } + .mapNotNull { ControlEdition.fromRumor(it.rumor) } + .filter { it.entityKind == ControlEntityKind.METADATA } + + assertEquals(1, carried.size, "one metadata edition carried forward") + assertEquals(50, carried.single().version, "the owner's real head, not the forged genesis") + assertEquals("Real", ConcordJson.decodeOrNull(carried.single().content)?.name) + } } diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord06Rekey/ControlRootRotationTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord06Rekey/ControlRootRotationTest.kt index bcd5eec1e5..7a4440e1a0 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord06Rekey/ControlRootRotationTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord06Rekey/ControlRootRotationTest.kt @@ -109,6 +109,7 @@ class ControlRootRotationTest { recipientsXOnly = listOf(owner.pubKey, moderator.pubKey, member.pubKey), staffXOnly = setOf(owner.pubKey, moderator.pubKey), createdAt = now, + ownerPubKey = owner.pubKey, ) val baseRekey = ConcordKeyDerivation.baseRekeyAddress(community.communityRoot, community.communityId, build.newEpoch) @@ -149,6 +150,7 @@ class ControlRootRotationTest { recipientsXOnly = listOf(owner.pubKey, member.pubKey), staffXOnly = setOf(owner.pubKey), createdAt = now, + ownerPubKey = owner.pubKey, ) // The rotator's own view writes; a member's view of the same epoch only reads. diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NeedGateTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NeedGateTest.kt new file mode 100644 index 0000000000..f404e96887 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NeedGateTest.kt @@ -0,0 +1,137 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip01Core.relay.client.accessories + +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertSame +import kotlin.test.assertTrue + +/** + * The one point on the download path where an id can still be declined for + * free. Everything asserted here is a property the inline version of this code + * could break silently: a sync with no predicate must not start allocating, a + * fully-declined batch must not become an empty REQ, and the skip count must + * stay separate from the reconcile's own diff so an operator can tell a + * predicate that does nothing from one that eats everything. + */ +class NeedGateTest { + private fun id(n: Int): HexKey = n.toString().padStart(64, '0') + + private fun batch(range: IntRange) = range.map(::id) + + private class Skips { + var total = 0 + var calls = 0 + + val sink: (Int) -> Unit = { + total += it + calls++ + } + } + + @Test + fun `with no predicate the batch passes through untouched and uncopied`() { + // The unfiltered sync is the common case and must not pay a copy per + // batch for a feature it is not using. + val skips = Skips() + val input = batch(1..500) + val kept = NeedGate(null, skips.sink).keep(input) + + assertSame(input, kept, "an unfiltered batch must be the same list instance, not a copy") + assertEquals(0, skips.total) + assertEquals(0, skips.calls, "nothing was dropped so the counter should not even be touched") + } + + @Test + fun `a predicate keeps its subset in order`() { + val skips = Skips() + val wanted = setOf(id(2), id(4), id(6)) + val kept = NeedGate({ it in wanted }, skips.sink).keep(batch(1..6)) + + assertEquals(listOf(id(2), id(4), id(6)), kept) + assertEquals(3, skips.total) + } + + @Test + fun `a fully declined batch yields an empty list and never null`() { + // Non-null on purpose. A nullable return invites + // `gate?.keep(ids) ?: ids`, which reads as "no gate, keep everything" + // and silently means "everything declined, so send everything" — a + // fully-declining gate turning into a full download. + val skips = Skips() + val kept = NeedGate({ false }, skips.sink).keep(batch(1..10)) + + assertTrue(kept.isEmpty(), "nothing survived, so there is nothing to send") + assertEquals(10, skips.total) + } + + @Test + fun `an empty input yields empty and counts nothing`() { + val skips = Skips() + assertTrue(NeedGate({ true }, skips.sink).keep(emptyList()).isEmpty()) + assertEquals(0, skips.total) + } + + @Test + fun `a predicate that accepts everything drops nothing`() { + val skips = Skips() + val kept = NeedGate({ true }, skips.sink).keep(batch(1..20)) + + assertEquals(20, kept.size) + assertEquals(0, skips.total) + assertEquals(0, skips.calls) + } + + @Test + fun `skips accumulate across batches`() { + // One gate spans a whole sync, so the count has to survive more than + // the batch it was produced in. + val skips = Skips() + val gate = NeedGate({ it.endsWith("1") }, skips.sink) + gate.keep(batch(1..10)) + gate.keep(batch(11..20)) + + assertEquals(18, skips.total, "only ids 1 and 11 of the twenty end in 1") + } + + @Test + fun `the predicate sees every id in the batch exactly once`() { + val seen = mutableListOf() + NeedGate({ + seen.add(it) + true + }, Skips().sink).keep(batch(1..5)) + + assertEquals(batch(1..5), seen) + } + + @Test + fun `a sync result reports no skips unless a predicate declined something`() { + // Back-compat: every existing caller constructs this without the new + // field and must keep reading zero. + val result = NegentropySyncResult(needCount = 7, haveCount = 0, downloaded = 7, windows = 1) + + assertEquals(0, result.skipped) + assertTrue(result.needCount == 7, "the reconcile diff is unaffected by the gate") + } +} diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientFetchAllPagesDrainTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientFetchAllPagesDrainTest.kt index f2de98b505..69b088dc40 100644 --- a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientFetchAllPagesDrainTest.kt +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientFetchAllPagesDrainTest.kt @@ -75,16 +75,23 @@ class NostrClientFetchAllPagesDrainTest { private val relay = RelayUrlNormalizer.normalize("wss://drain.example.com") - private fun event(createdAt: Long) = - Event( - id = createdAt.toString(16).padStart(64, '0'), - pubKey = "f".repeat(64), - createdAt = createdAt, - kind = 1, - tags = emptyArray(), - content = "e$createdAt", - sig = "0".repeat(128), - ) + /** + * [nonce] distinguishes two events sharing one `created_at`, which the id would + * otherwise collapse into the same event — and a boundary second holding more + * than one is the whole subject of the dense-second test below. + */ + private fun event( + createdAt: Long, + nonce: String = "", + ) = Event( + id = (createdAt.toString(16) + nonce).padStart(64, '0'), + pubKey = "f".repeat(64), + createdAt = createdAt, + kind = 1, + tags = emptyArray(), + content = "e$createdAt$nonce", + sig = "0".repeat(128), + ) @Test fun anEmptyPageConfirmedByEoseDrains() = @@ -224,4 +231,159 @@ class NostrClientFetchAllPagesDrainTest { assertEquals(PagedFetchResult.End.LIMIT_REACHED, result.end, "a fulfilled limit is the caller stopping, not the corpus ending") assertFalse(result.drained) } + + // ---- termination: the walk must END, whatever the relay does ------------- + + @Test + fun aBoundarySecondDenserThanAPageIsStillSteppedPast() = + runBlocking { + // The step-past path itself, which had no test and which the + // ignored-cursor guard now sits in front of. The two look identical + // from `delivered == 0` and must NOT be treated alike: a dense second + // returns events AT the boundary, so `aboveBoundary` stays 0 while + // `received` is 1, the guard holds its fire, and the walk steps past + // exactly as before. Only a relay answering ABOVE the boundary — which + // is not paging at all — trips it. + val client = ScriptedClient() + val feeder = + launch { + client.awaitPage(1) + client.listener!!.onEvent(event(2000), false, relay, null) + client.listener!!.onEvent(event(1000, "a"), false, relay, null) + client.listener!!.onEose(relay, null) + + // Page two re-asks second 1000 inclusively. The relay's page cap + // hands back the same head of that second — event "b" living + // there too can never be reached. Nothing new: stuck. + client.awaitPage(2) + client.listener!!.onEvent(event(1000, "a"), false, relay, null) + client.listener!!.onEose(relay, null) + + // So the walk steps strictly past to 999 and finds the corpus + // ends there. + client.awaitPage(3) + client.listener!!.onEose(relay, null) + } + + val result = + client.fetchAllPages( + relay = relay, + filters = listOf(Filter(kinds = listOf(1))), + idleTimeoutMs = 2_000, + ) { } + feeder.join() + + assertEquals(2, result.downloaded, "the duplicate is dropped, the dense second's tail is the documented loss") + assertEquals(3, client.subscribeCount, "it stepped past the stuck second instead of stopping on it") + assertEquals(PagedFetchResult.End.DRAINED, result.end, "and reached a genuinely empty, EOSEd page below it") + } + + @Test + fun aRelayThatIgnoresTheCursorEndsTheWalkInsteadOfSteppingForever() = + runBlocking { + // The production bug, scripted. purplepag.es holds events stamped + // `created_at = 0` and treats `until <= 0` as NO `until`, so the page + // below them comes back with its NEWEST events instead. None of those + // matches the filter's own `until`, so the page delivers nothing — + // which used to read as "the boundary second is too dense", step one + // second lower, and ask the identical unanswerable question again. + // Measured against the live relay: ~5.5 pages a second, 500 events + // discarded on each, an EOSE on every one, for as long as the process + // ran. `aboveBoundary == received` is what tells the two apart. + val client = ScriptedClient() + val feeder = + launch { + client.awaitPage(1) + client.listener!!.onEvent(event(2000), false, relay, null) + client.listener!!.onEvent(event(1000), false, relay, null) + client.listener!!.onEose(relay, null) + + // Page two asks for `until = 1000` and gets events from the top + // of the corpus — the answer to a query nobody made. + client.awaitPage(2) + client.listener!!.onEvent(event(9000), false, relay, null) + client.listener!!.onEvent(event(8000), false, relay, null) + client.listener!!.onEose(relay, null) + } + + val result = + client.fetchAllPages( + relay = relay, + filters = listOf(Filter(kinds = listOf(1))), + idleTimeoutMs = 2_000, + ) { } + feeder.join() + + assertEquals(2, result.downloaded, "only the two events the relay actually answered for") + assertEquals(2, client.subscribeCount, "and it stops on the FIRST page the relay refused to page") + assertEquals(PagedFetchResult.End.UNPAGEABLE, result.end, "a relay ignoring `until` is not paging, and cannot be stepped past") + assertFalse(result.drained, "which proves nothing about what it holds, so no coverage may be claimed") + } + + @Test + fun aCursorSteppingUnderTheEpochDrainsInsteadOfGoingNegative() = + runBlocking { + // `created_at` is unsigned, so nothing exists below epoch 0. A boundary + // second AT the epoch that only ever returns duplicates has reached the + // bottom of the time axis: the walk is done, and `until = -1` must never + // reach a relay — one of the five indexers CLOSEs the subscription over + // it, three answer a NOTICE and then never EOSE. + val client = ScriptedClient() + val feeder = + launch { + client.awaitPage(1) + client.listener!!.onEvent(event(0), false, relay, null) + client.listener!!.onEose(relay, null) + + // Page two re-asks the boundary inclusively and gets back only + // the event page one already delivered: nothing new, and nowhere + // left below to step to. + client.awaitPage(2) + client.listener!!.onEvent(event(0), false, relay, null) + client.listener!!.onEose(relay, null) + } + + val result = + client.fetchAllPages( + relay = relay, + filters = listOf(Filter(kinds = listOf(1))), + idleTimeoutMs = 2_000, + ) { } + feeder.join() + + assertEquals(1, result.downloaded, "the epoch event, delivered once") + assertEquals(2, client.subscribeCount, "no third page: there is nothing under zero to ask for") + assertEquals(PagedFetchResult.End.DRAINED, result.end, "the bottom of the time axis is an end, not a stall") + assertTrue(result.drained) + } + + @Test + fun anEventStampedBeforeTheEpochCannotPinTheWalk() = + runBlocking { + // `pageMinTs` is an event's own `created_at`, so one relay serving a + // negative timestamp drives the cursor under zero on the ADVANCE path + // rather than the step path. Clamping to 0 would not save it: such an + // event never equals the boundary, so it dodges the dedup and comes + // back on every page, pinning the walk at 0 for good. + val client = ScriptedClient() + val feeder = + launch { + client.awaitPage(1) + client.listener!!.onEvent(event(2000), false, relay, null) + client.listener!!.onEvent(event(-5), false, relay, null) + client.listener!!.onEose(relay, null) + } + + val result = + client.fetchAllPages( + relay = relay, + filters = listOf(Filter(kinds = listOf(1))), + idleTimeoutMs = 2_000, + ) { } + feeder.join() + + assertEquals(2, result.downloaded, "both events are still delivered — they were received") + assertEquals(1, client.subscribeCount, "but there is no second page to ask") + assertEquals(PagedFetchResult.End.DRAINED, result.end, "below the epoch there is nothing left to walk") + } } diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientNegentropySyncTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientNegentropySyncTest.kt index 6f4b121146..4524099b2c 100644 --- a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientNegentropySyncTest.kt +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientNegentropySyncTest.kt @@ -360,6 +360,123 @@ class NostrClientNegentropySyncTest : RelayClientTest() { } } + /** + * `wantId` declines ids BEFORE the download REQ, so the events never cross + * the wire — the point of putting the hook here rather than at `onEvent`. + * + * The assertion that matters is the one on the recorded `REQ` filters: a + * gate that merely dropped events after delivery would satisfy every other + * check in this test while saving nothing. + */ + @Test + fun wantIdSkipsIdsBeforeTheyAreEverRequested() = + runBlocking { + // Every id the relay was actually asked for, straight off the wire. + val requested = java.util.Collections.synchronizedList(mutableListOf()) + val recording = + object : PassThroughPolicy() { + override fun accept(cmd: ReqCmd): PolicyResult { + cmd.filters.forEach { f -> f.ids?.let { requested.addAll(it) } } + return PolicyResult.Accepted(cmd) + } + } + + val hub = InProcessRelays(defaultPolicy = { recording }) + val scope = CoroutineScope(Dispatchers.Default + SupervisorJob()) + val client = NostrClient(hub, scope) + try { + val url = RelayUrlNormalizer.normalize("ws://127.0.0.1:7791/") + val events = (1..20).map { SyntheticEvents.fakeEvent(idSeed = it, kind = 1) } + hub.getOrCreate(url).preload(events) + + // Decline the first ten by id. + val unwanted = events.take(10).map { it.id }.toSet() + + val got = mutableListOf() + val result = + withTimeout(30_000) { + client.negentropySync( + relay = url, + filter = Filter(kinds = listOf(1)), + idleTimeoutMs = 10_000L, + wantId = { it !in unwanted }, + ) { got.add(it) } + } + + assertEquals(10, got.size, "only the wanted half is delivered") + assertTrue(got.none { it.id in unwanted }, "no declined event was delivered") + assertEquals(10, result.downloaded) + assertEquals(10, result.skipped, "the declined ids are reported apart from the download count") + assertEquals( + 20, + result.needCount, + "needCount stays the honest protocol diff — the relay really did have all 20 that we lacked", + ) + + // The whole point: the declined ids were never asked for. + assertTrue( + requested.none { it in unwanted }, + "a declined id must never reach a REQ; requested = ${requested.filter { it in unwanted }}", + ) + // Every wanted id WAS asked for — so the gate declined the right + // half rather than simply starving the download. Asserted as a + // subset rather than a count because negentropySync also opens a + // keep-alive subscription carrying a sentinel id. + assertTrue( + requested.containsAll(events.drop(10).map { it.id }), + "every wanted id should still have been requested", + ) + } finally { + client.disconnect() + scope.cancel() + hub.close() + } + } + + /** With no `wantId` nothing changes: every id is fetched and `skipped` is 0. */ + @Test + fun withoutWantIdEveryIdIsStillRequested() = + runBlocking { + defaultRelay.preload(SyntheticEvents.batch(12, kind = 1)) + + val got = mutableListOf() + val result = + withTimeout(20_000) { + client.negentropySync( + relay = defaultRelayUrl, + filter = Filter(kinds = listOf(1)), + ) { got.add(it) } + } + + assertEquals(12, got.size) + assertEquals(0, result.skipped, "no predicate means nothing is ever skipped") + } + + /** + * A gate that declines everything must finish cleanly rather than hang: the + * empty batches are dropped instead of being queued as REQs for no ids. + */ + @Test + fun wantIdDecliningEverythingDownloadsNothingAndStillCompletes() = + runBlocking { + defaultRelay.preload(SyntheticEvents.batch(15, kind = 1)) + + val got = mutableListOf() + val result = + withTimeout(20_000) { + client.negentropySync( + relay = defaultRelayUrl, + filter = Filter(kinds = listOf(1)), + wantId = { false }, + ) { got.add(it) } + } + + assertTrue(got.isEmpty(), "nothing was wanted, so nothing is delivered") + assertEquals(0, result.downloaded) + assertEquals(15, result.skipped) + assertEquals(15, result.needCount, "the reconcile still saw the full diff") + } + /** * On a relay that reconciles fine, [negentropySyncOrFetch] uses negentropy and * does not page. diff --git a/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/prodbench/CursorTerminationProbe.kt b/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/prodbench/CursorTerminationProbe.kt new file mode 100644 index 0000000000..a0efae418e --- /dev/null +++ b/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/prodbench/CursorTerminationProbe.kt @@ -0,0 +1,167 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip01Core.relay.prodbench + +import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.fetchAllPages +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import com.vitorpamplona.quartz.nip01Core.relay.sockets.okhttp.BasicOkHttpWebSocket +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeoutOrNull +import okhttp3.OkHttpClient +import java.time.Duration +import kotlin.test.Test + +/** + * The live half of the paging termination guards, against the relay that found + * them. [NostrClientFetchAllPagesDrainTest] scripts this behaviour, so it pins our + * INTERPRETATION of a relay; only dialling one can say whether the interpretation + * matches anything real. + * + * ## What it walks, and why that relay + * + * purplepag.es holds twelve `kind 10002` events stamped `created_at = 0` and treats + * `until <= 0` as *no* `until`, answering with its five hundred NEWEST events. A + * cursor walk therefore reaches zero, gets a page it never asked for, delivers none + * of it, and — before the guards — stepped one second lower and asked again. Measured + * against the live relay: ~5.5 pages a second, 500 events fetched and discarded on + * each, an EOSE on *every* page, `until` marching one second further negative every + * time, for as long as the process ran. A cold walk pulled 1,490,010 real events in + * ~10.8 minutes and then never returned. + * + * The ceiling is set just above the epoch-stamped events rather than `now` on + * purpose: this relay serves ~2,300 kind 0/10002 events a second and holds years of + * them, so starting at the top would spend a quarter of an hour on history that is + * not what this measures. One page from [TRAP_CEILING] already carries them. + * + * ## Reading it + * + * `lowest until` is the whole tell. A walk that ends leaves it at a real timestamp; a + * walk that cannot end leaves it below zero. With the guards in place the expected + * report is `UNPAGEABLE` with the cursor never going under `0`. + * + * OFF by default and not a gate: it dials the public internet, so it is neither + * hermetic nor reproducible, and a relay being down is not a code regression. It + * asserts nothing for that reason — it REPORTS, and a human reads it. + * + * ``` + * ./gradlew :quartz:jvmTest --tests "*.CursorTerminationProbe" -PprodRelayBench=1 -i + * ``` + */ +class CursorTerminationProbe { + @Test + fun reportWhetherAPagedWalkTerminates() { + if (System.getenv("PROD_RELAY_BENCH") == null && System.getProperty("prodRelayBench") == null) { + println("reportWhetherAPagedWalkTerminates skipped. Run with -PprodRelayBench=1 to enable.") + return + } + val okhttp = + OkHttpClient + .Builder() + .connectTimeout(Duration.ofSeconds(20)) + .pingInterval(Duration.ofSeconds(120)) + .build() + val scope = CoroutineScope(SupervisorJob()) + val client = NostrClient(BasicOkHttpWebSocket.Builder { okhttp }, scope) + + println("=".repeat(78)) + println("Does a paged walk TERMINATE? kinds [0, 10002], from $TRAP_CEILING down") + println("=".repeat(78)) + try { + for (url in RELAYS) { + val relay = RelayUrlNormalizer.normalize(url) + var events = 0 + var pages = 0 + var lowest = Long.MAX_VALUE + val startedAt = System.currentTimeMillis() + val outcome = + runCatching { + runBlocking { + // A hard ceiling, which `fetchAllPages` deliberately does + // not have: its own doc says a walk is bounded by a + // `limit` or by cancelling the caller, and this is the + // caller cancelling. Without it a relay with no guard + // hangs the probe — which is exactly what it is here to + // detect, so it must be detected rather than suffered. + withTimeoutOrNull(TERMINATION_MS) { + client.fetchAllPages( + relay, + listOf(Filter(kinds = listOf(0, 10002), until = TRAP_CEILING)), + idleTimeoutMs = 20_000L, + onNewPage = { until -> + pages++ + if (until < lowest) lowest = until + }, + ) { events++ } + } + } + } + val took = System.currentTimeMillis() - startedAt + val verdict = + outcome.fold( + onSuccess = { r -> + when (r) { + null -> "NEVER ENDED in ${TERMINATION_MS / 1000}s — THE GUARD IS NOT WORKING" + else -> "${r.end} (${r.downloaded} event(s), drained=${r.drained})" + } + }, + onFailure = { "threw ${it::class.simpleName}: ${it.message}" }, + ) + val reached = if (lowest == Long.MAX_VALUE) "no page after the first" else "$lowest" + println(" %-26s %-52s".format(url.removePrefix("wss://"), verdict)) + println(" %-26s %d page(s), %d event(s), %dms, lowest until=%s".format("", pages, events, took, reached)) + } + } finally { + runCatching { client.disconnect() } + scope.cancel() + } + println("=".repeat(78)) + } + + companion object { + /** + * purplepag.es is the one that found this. The other four are controls: they + * hold nothing at all below `1.5e9`, so they drain in a single page and prove + * the guards did not change an ordinary walk. + */ + private val RELAYS = + listOf( + "wss://purplepag.es", + "wss://user.kindpag.es", + "wss://directory.yabu.me", + "wss://profiles.nostr1.com", + "wss://indexer.coracle.social", + ) + + /** Just above the `created_at = 0` events, so one page reaches the cursor that matters. */ + private const val TRAP_CEILING = 1_600_000_000L + + /** + * Not an idle timeout — the relay answers, with an EOSE, the entire time. + * This is how long a walk gets to prove it can END. + */ + private const val TERMINATION_MS = 45_000L + } +}