From 3d825aef04aaa2bf7700c14fe333225b0327a232 Mon Sep 17 00:00:00 2001 From: Chad Curtis Date: Mon, 1 Jun 2026 15:35:17 -0500 Subject: [PATCH] campaigns: hardcode moderators, gate lists on a single curator The home page used to serialize two single-relay round-trips before any campaign card could render: useCampaignModerators fetched the Team Soapbox follow pack (kind 39089), and useCampaignLists waited on it to apply an authors: gate. Each could stall up to an 8s EOSE timeout against the app relay. Both lookups are now eliminated from the critical path: - CAMPAIGN_MODERATORS in agoraDefaults.ts is a hardcoded snapshot of the pack's p tags. useCampaignModerators serves it synchronously (no queryFn network call), keeping its useQuery return shape so all ~15 consumers work unchanged. The roster changes rarely; update the array and re-cut a release when it does. - Lists are an editorial surface curated by one identity (MK Fain / Team Soapbox), not the whole moderator pack. useCampaignLists now pins authors: to LIST_CURATOR_PUBKEY and no longer depends on the moderator query at all. The multi-author allowlist remains for labels only (approve/hide), where any pack member is trusted. Regression-of: be1fadfc --- src/hooks/useCampaignLists.ts | 59 +++++++---------------- src/hooks/useCampaignModerators.ts | 77 ++++++++---------------------- src/lib/agoraDefaults.ts | 53 ++++++++++++++++++++ 3 files changed, 90 insertions(+), 99 deletions(-) diff --git a/src/hooks/useCampaignLists.ts b/src/hooks/useCampaignLists.ts index 92f3a732..eefeb44b 100644 --- a/src/hooks/useCampaignLists.ts +++ b/src/hooks/useCampaignLists.ts @@ -2,7 +2,6 @@ import { useNostr } from '@nostrify/react'; import { useQuery } from '@tanstack/react-query'; import { useMemo } from 'react'; -import { useCampaignModerators } from './useCampaignModerators'; import { CAMPAIGN_LIST_KIND, CAMPAIGN_LIST_HASHTAG, @@ -10,6 +9,7 @@ import { type ParsedCampaignList, foldCampaignLists, } from '@/lib/campaignLists'; +import { LIST_CURATOR_PUBKEY } from '@/lib/agoraDefaults'; import { DITTO_RELAY } from '@/lib/appRelays'; import type { NostrEvent } from '@nostrify/nostrify'; @@ -22,27 +22,19 @@ interface UseCampaignListsResult { } /** - * Reads moderator-curated campaign lists (kind 30003 with the + * Reads curator-authored campaign lists (kind 30003 with the * `agora.campaign-list` hashtag) plus the optional list-of-lists order * sentinel (`agora.campaign-lists.index`). * - * **Trust model.** Only lists authored by a {@link useCampaignModerators} - * allowlist member (Team Soapbox follow pack) are surfaced. Without that - * gate, any pubkey could publish a kind 30003 with our hashtag and appear - * in the strip — same self-appointment hole we avoid in - * `useCampaignModeration`. + * **Trust model.** Lists are an editorial surface curated by a single + * pubkey ({@link LIST_CURATOR_PUBKEY}). The relay query pins `authors:` + * to that pubkey, so a kind 30003 with our hashtag from anyone else — + * including a label moderator — never appears. This is deliberately + * narrower than label moderation (`useCampaignModerators`), where any + * follow-pack member is trusted to sign approve / hide labels. * - * **Flattened waterfall.** The list relay query no longer *waits* for the - * moderator pack to resolve. Previously the query was `enabled`-gated on - * the moderators and applied `authors: moderators` server-side, which - * serialized two single-relay round-trips (each up to an 8s EOSE timeout) - * on a cold session before any list could render. We now fire the list - * query immediately on the hashtag filter and apply the moderator - * allowlist **client-side** in {@link foldCampaignLists}. The two queries - * run in parallel; the trust gate is identical (a list authored by a - * non-moderator is dropped before it ever reaches the UI). The moderator - * pack is cached for 10 minutes, so on warm sessions it's already present - * and the filter applies with zero added latency. + * Because the curator is a hardcoded constant, this query depends on no + * other query — it fires on first paint with no waterfall. * * Lists *and* the index are pulled in a single filter via * `'#t': [LIST_HASHTAG, LIST_INDEX_HASHTAG]` so there's only one @@ -50,51 +42,32 @@ interface UseCampaignListsResult { */ export function useCampaignLists() { const { nostr } = useNostr(); - const { data: moderators, isLoading: moderatorsLoading } = useCampaignModerators(); - // Raw lists query — fired independently of the moderator pack so the two - // round-trips run in parallel. The moderator allowlist is applied at the - // fold step below, not as a server `authors:` filter. - const rawQuery = useQuery({ - queryKey: ['campaign-lists', 'raw'], + const query = useQuery({ + queryKey: ['campaign-lists', LIST_CURATOR_PUBKEY], queryFn: async ({ signal }) => { // Query the canonical app relay directly. The same reasoning as // `useCampaignModerators` applies: a fast empty EOSE from a // less-populated relay should not race the moderation surface to // "no lists" while the curated relay still holds them. const relay = nostr.relay(DITTO_RELAY); - return relay.query( + const events = await relay.query( [ { kinds: [CAMPAIGN_LIST_KIND], + authors: [LIST_CURATOR_PUBKEY], '#t': [CAMPAIGN_LIST_HASHTAG, CAMPAIGN_LIST_INDEX_HASHTAG], limit: 500, }, ], { signal: AbortSignal.any([signal, AbortSignal.timeout(8000)]) }, ); + return foldCampaignLists(events); }, staleTime: 30_000, }); - // Fold + trust-gate client-side: drop any list/index event not authored - // by a moderator before parsing. Recomputed when either the raw events - // or the moderator allowlist changes. - const data = useMemo(() => { - const events = rawQuery.data; - if (!events || !moderators || moderators.length === 0) { - return { lists: [], indexEvent: undefined }; - } - const allowed = new Set(moderators); - const trusted = events.filter((e) => allowed.has(e.pubkey)); - return foldCampaignLists(trusted); - }, [rawQuery.data, moderators]); - - return { - ...rawQuery, - data, - isLoading: rawQuery.isLoading || moderatorsLoading, - }; + return query; } /** Lookup a single list by slug from the cached collection. */ diff --git a/src/hooks/useCampaignModerators.ts b/src/hooks/useCampaignModerators.ts index adef3511..4793cee3 100644 --- a/src/hooks/useCampaignModerators.ts +++ b/src/hooks/useCampaignModerators.ts @@ -1,71 +1,36 @@ -import { useNostr } from '@nostrify/react'; import { useQuery } from '@tanstack/react-query'; -import { TEAM_SOAPBOX } from '@/lib/agoraDefaults'; -import { DITTO_RELAY } from '@/lib/appRelays'; - -/** A 64-character lowercase hex string. */ -const HEX_64_RE = /^[0-9a-f]{64}$/; +import { CAMPAIGN_MODERATORS } from '@/lib/agoraDefaults'; /** - * Returns the hex pubkeys of campaign moderators — the `p` tags of the - * Team Soapbox follow pack (kind 39089). + * Returns the hex pubkeys of campaign moderators — the pubkeys allowed to + * sign approve / hide labels in the `agora.moderation` namespace (see + * NIP.md). * * A campaign appears on `/` and Discover only if a moderator has labeled it * `approved` (see {@link useCampaignModeration}). A moderator's `hidden` - * label always wins over any approval. The pack itself is authored by a - * single admin pubkey, so we pin `authors` to that pubkey to prevent anyone - * else from publishing a same-`d` event and self-appointing. + * label always wins over any approval. * - * **Phase 1 tradeoff:** the pack is fetched live every cold session. We - * accept the 1-round-trip latency in exchange for not shipping a release - * every time the moderator roster changes. If perf matters, snapshot the - * `p` tags into a hardcoded array and short-circuit this hook. + * **Hardcoded snapshot.** This used to fetch the Team Soapbox follow pack + * (kind 39089) live every cold session, which put a single-relay round-trip + * — up to an 8s EOSE timeout — on the critical path of every + * moderation-gated surface (home, Discover, profile campaigns, etc.). The + * roster changes rarely, so the membership is now snapshotted in + * {@link CAMPAIGN_MODERATORS} and served synchronously with zero network + * cost. Update that array (and re-cut a release) when the pack changes. * - * @see TEAM_SOAPBOX (src/lib/agoraDefaults.ts) for the pack coordinate. + * The hook keeps its `useQuery` return shape so existing consumers + * (`{ data, isLoading, ... }`) continue to work unchanged; the query is a + * pure synchronous read with no `queryFn` network call. + * + * @see CAMPAIGN_MODERATORS (src/lib/agoraDefaults.ts) for the pubkey list. * @see NIP.md "Campaign moderation labels" for the namespace this powers. */ export function useCampaignModerators() { - const { nostr } = useNostr(); - return useQuery({ - queryKey: ['campaign-moderators', TEAM_SOAPBOX.pubkey, TEAM_SOAPBOX.identifier], - queryFn: async ({ signal }) => { - // The home page gates campaign visibility on this pack. Query the - // canonical app relay directly so a fast empty EOSE from another relay - // cannot race the pack out and make the page render as empty. - const relay = nostr.relay(DITTO_RELAY); - const events = await relay.query( - [ - { - kinds: [TEAM_SOAPBOX.kind], - // Pinning to the pack author is required: kind 39089 is - // addressable, so without this anyone could publish a competing - // event with the same `d` and force themselves into the moderator - // list. (See AGENTS.md `nostr-security`.) - authors: [TEAM_SOAPBOX.pubkey], - '#d': [TEAM_SOAPBOX.identifier], - limit: 1, - }, - ], - { signal: AbortSignal.any([signal, AbortSignal.timeout(8000)]) }, - ); - - if (events.length === 0) return [] as string[]; - - // The pack is replaceable; relays may serve old revisions alongside the - // current one. Keep the newest. - const newest = events.reduce((latest, current) => - current.created_at > latest.created_at ? current : latest, - ); - - // Filter malformed `p` tags so a typo doesn't blow up downstream - // relay filters (which reject non-hex `authors:` entries). - return newest.tags - .filter(([name, value]) => name === 'p' && typeof value === 'string' && HEX_64_RE.test(value)) - .map(([, pubkey]) => pubkey); - }, - staleTime: 10 * 60_000, - gcTime: 60 * 60_000, + queryKey: ['campaign-moderators', 'snapshot'], + queryFn: () => CAMPAIGN_MODERATORS.slice(), + staleTime: Infinity, + gcTime: Infinity, }); } diff --git a/src/lib/agoraDefaults.ts b/src/lib/agoraDefaults.ts index e7f3c8e1..008414e1 100644 --- a/src/lib/agoraDefaults.ts +++ b/src/lib/agoraDefaults.ts @@ -60,3 +60,56 @@ export const TEAM_SOAPBOX = { identifier: teamSoapboxDecoded.data.identifier, relays: teamSoapboxDecoded.data.relays, } as const; + +/** + * The single pubkey allowed to author campaign **lists** (kind 30003 with + * the `agora.campaign-list` hashtag) and the list-of-lists index sentinel. + * + * This is deliberately narrower than the moderator allowlist + * ({@link CAMPAIGN_MODERATORS}). That allowlist governs **labels** — + * approve / hide moderation in the `agora.moderation` namespace — where + * any pack member is trusted to sign. Lists are an editorial surface (the + * home hero row, the topic strip) curated by one person (MK Fain / Team + * Soapbox), so a list authored by anyone else — including another + * moderator — is dropped before it reaches the UI. + * + * It happens to equal the follow-pack author (`TEAM_SOAPBOX.pubkey`), + * which is the same single admin identity, so we derive it from there + * rather than duplicating the hex. + */ +export const LIST_CURATOR_PUBKEY = TEAM_SOAPBOX.pubkey; + +/** + * Hardcoded snapshot of the campaign-moderator pubkeys — the `p` tags of + * the Team Soapbox follow pack ({@link TEAM_SOAPBOX}) as of the snapshot + * date below. + * + * These pubkeys form the authoritative allowlist for **labels**: who may + * sign approve / hide moderation in the `agora.moderation` namespace (see + * NIP.md and `useCampaignModerators`). A campaign appears on `/` and + * Discover only if one of these pubkeys labeled it `approved`; a `hidden` + * label from any of them always wins. + * + * **Why hardcoded.** The pack used to be fetched live every cold session + * (kind 39089), which put a single-relay round-trip — up to an 8s EOSE + * timeout — on the critical path of every moderation-gated surface. The + * roster changes rarely, so we snapshot it here and pay zero network cost. + * When the pack membership changes, update this array (and re-cut a + * release). Source of truth remains the on-relay pack; this is a copy. + * + * Snapshot taken from pack event `740838e6…fac76` (created_at 1779321391). + */ +export const CAMPAIGN_MODERATORS: readonly string[] = [ + '781a1527055f74c1f70230f10384609b34548f8ab6a0a6caa74025827f9fdae5', + '0461fcbecc4c3374439932d6b8f11269ccdb7cc973ad7a50ae362db135a474dd', + '932614571afcbad4d17a191ee281e39eebbb41b93fac8fd87829622aeb112f4d', + '3f770d65d3a764a9c5cb503ae123e62ec7598ad035d836e2a810f3877a745b24', + '86184109eae937d8d6f980b4a0b46da4ef0d983eade403ee1b4c0b6bde238b47', + '47259076c85f9240e852420d7213c95e95102f1de929fb60f33a2c32570c98c4', + 'ce97367c75d7d91fb9bc3bc6ff5bb3bdb52c18941bfce2f368616dcbf0adfd2f', + '0574536d3ef4d65faf95b42393610b8475d22f4c294649d46c50d5d36f75267c', + 'be7358c4fe50148cccafc02ea205d80145e253889aa3958daafa8637047c840e', + '2093baa8621c5b255e8f4fc2c6fdfc10d8a5598a25517664efaba860735f1030', + '8f53782e8693e88afb710b6d68182ad973973c8822caa237bb60288b125673ca', + 'c839bc85846f24fc6b777548fe654672377f4cc2a04cab19cddec75b2f8b4dbd', +] as const;