diff --git a/NIP.md b/NIP.md index c198945e..f519696f 100644 --- a/NIP.md +++ b/NIP.md @@ -468,7 +468,7 @@ Parent, child, sister, and rank relationships are intentionally out of scope for ### Membership Derivation -Community membership is derived from three sources: +Membership is sourced from the community definition and from validated kind `8` membership awards. This produces three populations: - **Founder** -- the `pubkey` field on the kind `34550` event. One per community, immutable. Controls the community definition since only they can republish the addressable event. - **Moderators** -- the `p` tags on the kind `34550` event with role `"moderator"` (matching [NIP-72](https://github.com/nostr-protocol/nips/blob/master/72.md)). Mutable by republishing the community definition. @@ -585,7 +585,7 @@ The `authors` filter is the primary membership-award trust boundary. Awards from Community-scoped content is any event that tags the community definition with uppercase `A`. The foundation implementation starts with kind `1111` ([NIP-22](https://github.com/nostr-protocol/nips/blob/master/22.md)) posts, but the same moderation overlay applies to future community content kinds such as calendar events, polls, listings, or other domain-specific events. -Clients SHOULD treat valid community members as the canonical authors for community views. Content from non-members MAY be shown in future review surfaces, but canonical community feeds SHOULD discard non-member content by default. +Clients MAY offer a members-only view that filters community posts down to the resolved member set as an `authors` filter. Whether this is on by default, opt-in, or omitted entirely is a client UX choice -- the protocol makes no recommendation. #### Community Post diff --git a/src/components/CommunityDetailPage.tsx b/src/components/CommunityDetailPage.tsx index dcc56fdd..9153987f 100644 --- a/src/components/CommunityDetailPage.tsx +++ b/src/components/CommunityDetailPage.tsx @@ -303,8 +303,8 @@ export function CommunityDetailPage({ event }: { event: NostrEvent }) { // Filter: omit banned events and posts by banned members, then optionally // restrict to validated members when the "members only" toggle is - // active. The member filter is a presentation-layer choice — the NIP - // recommends it as the canonical-feed default, but users may opt out. + // active. The member filter is a presentation-layer opt-in — the NIP + // lists it as a MAY feature, so users default to seeing everything. const applyModeration = (events: NostrEvent[]): NostrEvent[] => { const moderated = applyCommunityModerationToEvents(events, moderation); if (!membersOnly) return moderated; diff --git a/src/components/MembersOnlyToggle.tsx b/src/components/MembersOnlyToggle.tsx index 3cd335b8..c3bbc346 100644 --- a/src/components/MembersOnlyToggle.tsx +++ b/src/components/MembersOnlyToggle.tsx @@ -10,10 +10,12 @@ interface MembersOnlyToggleProps { /** * Shield-icon toggle that controls the "members only" filter for community - * surfaces. When active (default), community feeds only show content authored - * by validated members — matching the NIP's canonical-author - * recommendation. When inactive, the feed shows every event scoped to the - * community regardless of author. + * surfaces. When active, community feeds only show content authored by + * validated members. When inactive (default), the feed shows every event + * scoped to the community regardless of author. + * + * Per the flat-communities spec, members-only is a MAY feature — the + * protocol makes no recommendation, so the toggle is an opt-in UX choice. * * The preference is persisted in localStorage via `useMembersOnlyFilter` and * is global across community surfaces (Activities feed, per-community diff --git a/src/hooks/useMembersOnlyFilter.ts b/src/hooks/useMembersOnlyFilter.ts index 79814062..fd12fc44 100644 --- a/src/hooks/useMembersOnlyFilter.ts +++ b/src/hooks/useMembersOnlyFilter.ts @@ -10,10 +10,10 @@ const STORAGE_KEY = 'community:members-only'; * Controls whether community views filter content down to posts authored by * validated members, or show everything scoped to the community. * - * Defaults to `true` (members-only), which aligns with the NIP's "canonical - * community feeds SHOULD discard non-member content by default" guidance - * (see NIP.md §Community-Scoped Content). Users can opt out per their - * preference via a shield-icon toggle in the UI. + * Defaults to `false` (show everything). The flat-communities spec treats + * members-only as a MAY feature (see NIP.md §Community-Scoped Content) — + * the protocol makes no recommendation, so the default is the broader view + * and users opt in via the shield-icon toggle. * * Implementation: a module-level singleton store (Set of subscribers + * cached boolean). Every component mounting `useMembersOnlyFilter` shares @@ -23,21 +23,21 @@ const STORAGE_KEY = 'community:members-only'; * via the browser's `storage` event. */ -/** Read the persisted boolean, defaulting to `true` when absent or malformed. */ +/** Read the persisted boolean, defaulting to `false` when absent or malformed. */ function readFromStorage(): boolean { try { const raw = localStorage.getItem(STORAGE_KEY); - if (raw === null) return true; + if (raw === null) return false; return JSON.parse(raw) === true; } catch { - return true; + return false; } } // ── Module-level singleton store ──────────────────────────────────────────── // Module initialisation accesses `localStorage` which is unavailable in some // SSR-ish environments. Guard so the module can still be imported. -let cached: boolean = typeof localStorage !== 'undefined' ? readFromStorage() : true; +let cached: boolean = typeof localStorage !== 'undefined' ? readFromStorage() : false; const subscribers = new Set<() => void>(); function notify() { diff --git a/src/hooks/useMyCommunities.ts b/src/hooks/useMyCommunities.ts index 60727bc1..76a7380b 100644 --- a/src/hooks/useMyCommunities.ts +++ b/src/hooks/useMyCommunities.ts @@ -7,6 +7,7 @@ import { COMMUNITIES_LIST_KIND, parseCommunityBookmarkATag } from './useCommunit import { COMMUNITY_DEFINITION_KIND, BADGE_AWARD_KIND, + isAuthorizedAward, parseCommunityEvent, type ParsedCommunity, } from '@/lib/communityUtils'; @@ -166,9 +167,8 @@ export function useMyCommunities() { const community = parseCommunityEvent(event); if (!community) continue; if (!community.memberBadgeATag || !badgeATags.has(community.memberBadgeATag)) continue; - const authorizedAwarders = new Set([community.founderPubkey, ...community.moderatorPubkeys]); const hasValidAward = (awardsByBadgeATag.get(community.memberBadgeATag) ?? []) - .some((award) => authorizedAwarders.has(award.pubkey)); + .some((award) => isAuthorizedAward(award, community)); if (!hasValidAward) continue; if (seen.has(community.aTag)) continue; seen.set(community.aTag, { diff --git a/src/lib/communityUtils.ts b/src/lib/communityUtils.ts index 77fd4230..ebdf75d5 100644 --- a/src/lib/communityUtils.ts +++ b/src/lib/communityUtils.ts @@ -423,9 +423,16 @@ export function resolveCommunityModeration( // ── Pass 1: Resolve bans in authority order ──────────────────────── // - // Rank 0 means founder/moderator and rank 1 means member. The existing - // numeric check maps to the flat authority model: leadership can ban - // members/non-members, while members can only ban non-members (Infinity). + // Rank 0 means founder/moderator and rank 1 means member. Non-members + // are treated as lowest rank (Infinity), so members can only ban + // non-members while founder/moderators can ban anyone. + // + // Candidates are sorted by reporter rank ascending so leadership bans + // are resolved before member bans. Because authority is strict + // (`reporter.rank < target.rank`), a banned reporter can never appear + // earlier in the sorted list than whoever banned them — so no extra + // `bannedPubkeys.has(reporter)` check is needed here. Pass 2 handles + // the remaining case (soft reports from members who end up banned). interface BanCandidate { parsed: CommunityReport; @@ -437,22 +444,19 @@ export function resolveCommunityModeration( for (const p of parsed) { if (p.action !== 'content-ban' && p.action !== 'member-ban') continue; - // Reporter is guaranteed to be a member (filtered above). - const reporter = members.get(p.reporterPubkey)!; - - // Authority check: reporter rank must be strictly less than target rank. - // Non-members are treated as lowest rank (Infinity). + // Reporter membership is guaranteed by the parse-time filter above. + const reporterRank = members.get(p.reporterPubkey)!.rank; const targetRank = members.get(p.targetPubkey)?.rank ?? Infinity; - if (reporter.rank >= targetRank) continue; - banCandidates.push({ parsed: p, reporterRank: reporter.rank }); + // Authority check: strict rank inequality. + if (reporterRank >= targetRank) continue; + + banCandidates.push({ parsed: p, reporterRank }); } banCandidates.sort((a, b) => a.reporterRank - b.reporterRank); for (const { parsed: p } of banCandidates) { - if (bannedPubkeys.has(p.reporterPubkey)) continue; - if (p.action === 'content-ban' && p.targetEventId) { const existing = contentBansByEventId.get(p.targetEventId) ?? []; existing.push({ @@ -485,11 +489,34 @@ export function resolveCommunityModeration( } /** - * Resolve flat community membership from founder/moderators plus membership - * awards already queried with `authors: [founder, ...moderators]`. + * Whether a kind 8 badge award is a valid membership award for a community. * - * This resolver intentionally does not re-check award authors. The relay query - * authors filter is the trust boundary for authorized awarders. + * Three conditions must hold (per NIP.md §Badge Awards): + * 1. The event is a kind 8 badge award. + * 2. The award author is the founder or a current moderator of the community. + * 3. The award contains an `a` tag referencing the community's member badge. + * + * This is the single source of truth for award authorization. Both the + * membership resolver and any discovery path that reaches awards through + * an unfiltered query (e.g. `#p`-based "communities I belong to" lookups) + * MUST apply this check before trusting the award. + */ +export function isAuthorizedAward(award: NostrEvent, community: ParsedCommunity): boolean { + if (award.kind !== BADGE_AWARD_KIND) return false; + if (!community.memberBadgeATag) return false; + if (award.pubkey !== community.founderPubkey && !community.moderatorPubkeys.includes(award.pubkey)) return false; + return award.tags.some(([n, v]) => n === 'a' && v === community.memberBadgeATag); +} + +/** + * Resolve flat community membership from founder/moderators plus membership + * awards. + * + * Each award is validated via `isAuthorizedAward`. Callers SHOULD still query + * with `authors: [founder, ...moderators]` so the relay indexes the trust + * boundary, but this resolver enforces the same check client-side so that + * discovery paths which reach awards by other filters (e.g. `#p` on the + * viewer) stay consistent. */ export function resolveMembership( community: ParsedCommunity, @@ -507,26 +534,22 @@ export function resolveMembership( } } - if (community.memberBadgeATag) { - for (const award of awardEvents) { - if (award.kind !== BADGE_AWARD_KIND) continue; - const badgeATag = award.tags.find(([n, v]) => n === 'a' && v === community.memberBadgeATag)?.[1]; - if (!badgeATag) continue; + for (const award of awardEvents) { + if (!isAuthorizedAward(award, community)) continue; - const recipients = award.tags - .filter(([n]) => n === 'p') - .map(([, pk]) => pk) - .filter((pk): pk is string => !!pk && HEX_PUBKEY_RE.test(pk)); + const recipients = award.tags + .filter(([n]) => n === 'p') + .map(([, pk]) => pk) + .filter((pk): pk is string => !!pk && HEX_PUBKEY_RE.test(pk)); - for (const recipientPk of recipients) { - if (validated.has(recipientPk)) continue; - validated.set(recipientPk, { - pubkey: recipientPk, - rank: 1, - awardEvent: award, - awardedBy: award.pubkey, - }); - } + for (const recipientPk of recipients) { + if (validated.has(recipientPk)) continue; + validated.set(recipientPk, { + pubkey: recipientPk, + rank: 1, + awardEvent: award, + awardedBy: award.pubkey, + }); } }