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:
@@ -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
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user