Tighten flat community primitives

Extract isAuthorizedAward helper as the single source of truth for
membership award validation, used by both resolveMembership and
useMyCommunities. Simplify resolveCommunityModeration by dropping
the dead banned-reporter guard from pass 1 (impossible under strict
rank ordering). Flip useMembersOnlyFilter default to opt-in to match
the spec's MAY wording, and reword the NIP to match.
This commit is contained in:
lemon
2026-05-06 21:45:08 -07:00
parent 6b72d20af8
commit ea4295cb89
6 changed files with 77 additions and 52 deletions
+2 -2
View File
@@ -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
+2 -2
View File
@@ -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;
+6 -4
View File
@@ -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
+8 -8
View File
@@ -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() {
+2 -2
View File
@@ -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, {
+57 -34
View File
@@ -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,
});
}
}